From 1bb2a6f18ec91f7543f81d0b89f2d9fbf470f312 Mon Sep 17 00:00:00 2001 From: alkoleft Date: Mon, 11 May 2026 22:19:22 +0300 Subject: [PATCH 01/16] feat(config): initialize local project config - create v8project.local.yaml during config init - add gitignore coverage using effective git ignore checks - document local overlay bootstrap behavior --- README.md | 5 +- SKILL/SKILL.md | 4 +- SKILL/references/config-and-backends.md | 2 + SKILL/references/project-workflows.md | 3 + docs/CAPABILITIES.md | 4 +- docs/CONFIGURATION.md | 18 +- src/app.rs | 2 + src/domain/config_init.rs | 2 + src/platform/git.rs | 25 ++ src/platform/mod.rs | 1 + src/use_cases/config_init.rs | 356 +++++++++++++++++++++++- tests/cli_config_init.rs | 40 +++ 12 files changed, 445 insertions(+), 17 deletions(-) create mode 100644 src/platform/git.rs diff --git a/README.md b/README.md index e39aa89..b9965f2 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ cargo build --release v8-runner config init ``` -Команда анализирует структуру проекта, находит поддержанные `source-set` (наборы исходников) и -создает `v8project.yaml`. +Команда анализирует структуру проекта, находит поддержанные `source-set` (наборы исходников), +создает `v8project.yaml`, пустой `v8project.local.yaml` со schema modeline и добавляет local +overlay в `.gitignore`, если он еще не указан. Machine-local пути, credentials и настройки инструментов можно вынести в `v8project.local.yaml` рядом с основным конфигом. Этот файл применяется автоматически и должен оставаться вне Git. diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 33b1b03..ec6c6c4 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -22,7 +22,7 @@ Use the available `v8-runner` binary directly. If it is not on `PATH`, ask for t `v8project.yaml` is the default project config name. A sibling `v8project.local.yaml` is loaded automatically for machine-local paths, credentials, tools, tests, and MCP settings. Do not pass `--config v8project.yaml` unless the user explicitly wants a non-default command shape or the active config path differs from the default; never pass `v8project.local.yaml` as `--config`. -Generated `v8project.yaml` files include a `yaml-language-server` modeline that points to the versioned JSON Schema for the current `v8-runner` release. For `v8project.local.yaml`, use the matching `docs/schemas/v8project.local.schema.json` raw GitHub tag URL in editor settings when schema-assisted editing matters. +Generated `v8project.yaml` files include a `yaml-language-server` modeline that points to the versioned JSON Schema for the current `v8-runner` release. `config init` also creates sibling `v8project.local.yaml` with the local overlay schema modeline and adds it to `.gitignore` when needed. Use JSON output only when another tool, script, or final answer needs structured results: @@ -45,7 +45,7 @@ Useful global flags: 1. Check whether `v8project.yaml` exists in the 1C project root. 2. If it is missing, run the narrowest `v8-runner config init ...` command that fits the project shape. -3. Inspect the generated config before running mutating commands. +3. Inspect generated `v8project.yaml` and keep machine-local overrides in generated `v8project.local.yaml`. 4. Run `v8-runner init` only when the file infobase or EDT workspace needs to be created. 5. Run the narrowest validation command that answers the user's goal. diff --git a/SKILL/references/config-and-backends.md b/SKILL/references/config-and-backends.md index 545b5c8..7b5d075 100644 --- a/SKILL/references/config-and-backends.md +++ b/SKILL/references/config-and-backends.md @@ -53,3 +53,5 @@ Prefer `--source-set ` for narrow build, dump, convert, and artifact flows `v8project.local.yaml` is an automatic local overlay only. It may override only `workPath`, `infobase.*`, `tools.*`, `tests.*`, and `mcp.*`; it must not define `source-set`, `format`, or `builder`, and it must not be used as `--config`. `--workdir` wins over both config files. +`config init` creates the sibling local overlay as an empty mapping with a schema modeline and adds +`v8project.local.yaml` to `.gitignore` when needed. diff --git a/SKILL/references/project-workflows.md b/SKILL/references/project-workflows.md index 4f94c47..76cc98a 100644 --- a/SKILL/references/project-workflows.md +++ b/SKILL/references/project-workflows.md @@ -12,6 +12,9 @@ Create the default config when the project has no `v8project.yaml`: v8-runner config init ``` +This creates `v8project.yaml`, a sibling empty `v8project.local.yaml` for machine-local overrides, +and a `.gitignore` entry for the local overlay when needed. + Choose a narrower init command only when the project shape is known: ```bash diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 91dbae4..6bc9ccb 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -20,7 +20,7 @@ CLI help, доверяйте текущему коду и затем синхр | Сценарий | Поддерживаемые комбинации | Примечания | | --- | --- | --- | -| `config init` | Работает без существующего конфига | Создаёт `v8project.yaml`, autodetect-ит supported `source-set` и aggregate external roots | +| `config init` | Работает без существующего конфига | Создаёт `v8project.yaml`, sibling `v8project.local.yaml`, `.gitignore` entry, autodetect-ит supported `source-set` и aggregate external roots | | `init` | `format=DESIGNER` + `builder=DESIGNER` | Создаёт файловую ИБ через Designer; server connection остаётся manual prerequisite | | `init` | `format=DESIGNER` + `builder=IBCMD` | Выполняет `ensure` файловой или серверной ИБ через `ibcmd infobase create` | | `init` | `format=EDT` + `builder=DESIGNER|IBCMD` | Готовит ИБ по правилам builder и импортирует EDT workspace | @@ -74,6 +74,8 @@ v8-runner config init [--force] [--output ] [--connection ] [- - Не требует существующего `v8project.yaml`. - Пишет результат в текущий каталог или в `--output`. +- Рядом с primary config создает/обновляет пустой `v8project.local.yaml` со schema modeline и + добавляет `v8project.local.yaml` в `.gitignore`, если подходящий pattern еще не указан. - Не использует глобальный `--config` как shortcut output path. - Ищет supported `DESIGNER` / `EDT` `source-set` по marker files и их содержимому. - Для external roots создаёт aggregate `source-set` только при однородной классификации каталога. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f3adbfa..e2ae8d0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -33,6 +33,9 @@ v8-runner config init - создаёт `v8project.yaml` в текущем каталоге или по `--output `; - добавляет modeline `yaml-language-server` со ссылкой на versioned schema для текущей версии `v8-runner`; +- создаёт рядом пустой `v8project.local.yaml` с modeline на + `https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json`; +- добавляет `v8project.local.yaml` в `.gitignore`, если подходящий pattern еще не указан; - заполняет `source-set` по найденным исходникам; - не перезаписывает существующий файл без `--force`; - не пишет synthetic `CONFIGURATION`: если конфигурационный `source-set` не найден, @@ -74,9 +77,14 @@ schema artifacts для редактирования `v8project.yaml` и `v8proj В VS Code установите расширение `redhat.vscode-yaml`. Оно использует эту строку автоматически; отдельная настройка workspace для основного файла не нужна. -Для `v8project.local.yaml` schema подключается вручную через настройки VS Code, потому что local -overlay обычно не генерируется командой. Добавьте это в `.vscode/settings.json` проекта или в -user settings: +Для `v8project.local.yaml` `config init` пишет отдельную modeline: + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json +``` + +Если local overlay создаётся вручную, добавьте это в `.vscode/settings.json` проекта или в user +settings: ```json { @@ -187,7 +195,9 @@ tests: ## Локальный overlay `v8project.local.yaml` расположен рядом с выбранным primary config и применяется автоматически. -Файл не является самостоятельным config entrypoint: передавать его через `--config` нельзя. +`config init` создаёт пустой local overlay как валидный YAML mapping (`{}`), добавляет schema +modeline и сохраняет существующие значения, если файл уже был создан вручную. Файл не является +самостоятельным config entrypoint: передавать его через `--config` нельзя. Precedence: diff --git a/src/app.rs b/src/app.rs index 406074f..72bf012 100644 --- a/src/app.rs +++ b/src/app.rs @@ -184,6 +184,8 @@ fn render_config_init_text( ) { let mut details = vec![ format!("path: {}", result.path), + format!("local path: {}", result.local_path), + format!("gitignore: {}", result.gitignore_path), format!("format: {}", result.format), format!("builder: {}", result.builder), ]; diff --git a/src/domain/config_init.rs b/src/domain/config_init.rs index b61da31..d74f56f 100644 --- a/src/domain/config_init.rs +++ b/src/domain/config_init.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; pub struct ConfigInitResult { pub ok: bool, pub path: String, + pub local_path: String, + pub gitignore_path: String, pub format: String, pub builder: String, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/platform/git.rs b/src/platform/git.rs new file mode 100644 index 0000000..6bba859 --- /dev/null +++ b/src/platform/git.rs @@ -0,0 +1,25 @@ +use std::path::Path; +use std::process::{Command, Stdio}; + +/// Returns Git's effective ignore decision for `path`. +/// +/// `None` means Git is unavailable, `path` is outside a worktree, or Git reported +/// an execution error that should fall back to local `.gitignore` editing. +pub fn check_ignored(path: &Path) -> Option { + let workdir = path.parent().unwrap_or_else(|| Path::new(".")); + let status = Command::new("git") + .arg("-C") + .arg(workdir) + .args(["check-ignore", "--quiet", "--"]) + .arg(path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok()?; + + match status.code() { + Some(0) => Some(true), + Some(1) => Some(false), + _ => None, + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 45074fa..578d14b 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -3,6 +3,7 @@ pub mod designer; pub mod edt; pub mod edt_session; pub mod enterprise; +pub mod git; pub mod ibcmd; pub mod interactive; pub mod locator; diff --git a/src/use_cases/config_init.rs b/src/use_cases/config_init.rs index 0ddbed1..a8da4d7 100644 --- a/src/use_cases/config_init.rs +++ b/src/use_cases/config_init.rs @@ -11,6 +11,9 @@ use crate::support::source_descriptor::{ self, SourceDescriptorParseError, SourceDescriptorPurpose, SourceSetRootScanError, }; +const LOCAL_CONFIG_FILE_NAME: &str = "v8project.local.yaml"; +const LOCAL_CONFIG_SCHEMA_MODEL_LINE: &str = "# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json"; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigInitRequest { pub project_dir: PathBuf, @@ -73,24 +76,30 @@ pub fn execute(request: &ConfigInitRequest) -> Result Result Result<(), AppError> { + let content = if path.exists() { + let existing = std::fs::read_to_string(path).map_err(|error| { + AppError::Runtime(format!( + "failed to read local config file '{}': {error}", + path.display() + )) + })?; + with_local_schema_modeline(&existing) + } else { + render_empty_local_config() + }; + + std::fs::write(path, content).map_err(|error| { + AppError::Runtime(format!( + "failed to write local config file '{}': {error}", + path.display() + )) + }) +} + +fn with_local_schema_modeline(existing: &str) -> String { + if existing.trim().is_empty() { + return render_empty_local_config(); + } + + let mut lines = existing.lines(); + let first_line = lines.next().unwrap_or_default(); + let mut content = String::new(); + if first_line + .trim_start() + .starts_with("# yaml-language-server: $schema=") + { + content.push_str(LOCAL_CONFIG_SCHEMA_MODEL_LINE); + content.push('\n'); + let remainder = lines.collect::>().join("\n"); + if !remainder.is_empty() { + content.push_str(&remainder); + content.push('\n'); + } + } else { + content.push_str(LOCAL_CONFIG_SCHEMA_MODEL_LINE); + content.push('\n'); + content.push_str(existing); + if !existing.ends_with('\n') { + content.push('\n'); + } + } + if yaml_document_is_empty(&content) { + content.push_str("{}\n"); + } + content +} + +fn render_empty_local_config() -> String { + format!("{LOCAL_CONFIG_SCHEMA_MODEL_LINE}\n{{}}\n") +} + +fn yaml_document_is_empty(content: &str) -> bool { + matches!( + serde_yaml::from_str::(content), + Ok(serde_yaml::Value::Null) + ) +} + +fn ensure_gitignore_ignores_local_config( + local_config_path: &Path, + gitignore_path: &Path, +) -> Result<(), AppError> { + match crate::platform::git::check_ignored(local_config_path) { + Some(true) => Ok(()), + Some(false) => append_local_config_gitignore_pattern(gitignore_path, false), + None => append_local_config_gitignore_pattern(gitignore_path, true), + } +} + +fn append_local_config_gitignore_pattern( + path: &Path, + skip_existing_pattern: bool, +) -> Result<(), AppError> { + if path.exists() { + let existing = std::fs::read_to_string(path).map_err(|error| { + AppError::Runtime(format!( + "failed to read gitignore file '{}': {error}", + path.display() + )) + })?; + if skip_existing_pattern && gitignore_mentions_local_config(&existing) { + return Ok(()); + } + + let mut content = existing; + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str("v8project.local.yaml\n"); + std::fs::write(path, content).map_err(|error| { + AppError::Runtime(format!( + "failed to write gitignore file '{}': {error}", + path.display() + )) + })?; + return Ok(()); + } + + std::fs::write(path, "v8project.local.yaml\n").map_err(|error| { + AppError::Runtime(format!( + "failed to write gitignore file '{}': {error}", + path.display() + )) + }) +} + +fn gitignore_mentions_local_config(content: &str) -> bool { + content.lines().any(|line| { + let pattern = line.trim(); + !pattern.is_empty() + && !pattern.starts_with('#') + && !pattern.starts_with('!') + && (pattern == LOCAL_CONFIG_FILE_NAME + || pattern == "/v8project.local.yaml" + || pattern == "**/v8project.local.yaml") + }) +} + fn resolve_output_path(project_dir: &Path, output_path: &Path) -> PathBuf { if output_path.is_absolute() { output_path.to_path_buf() @@ -789,6 +923,7 @@ mod tests { }; use crate::config::loader::load_config; use std::path::Path; + use std::process::Command; use tempfile::tempdir; fn write_file(path: &Path, contents: &str) { @@ -821,6 +956,16 @@ mod tests { write_file(&project_dir.join("src").join("root.xml"), descriptor_xml); } + fn init_git_repo(path: &Path) { + let status = Command::new("git") + .arg("init") + .arg("-q") + .arg(path) + .status() + .expect("run git init"); + assert!(status.success()); + } + fn create_native_edt_project( project_dir: &Path, name: &str, @@ -998,6 +1143,201 @@ mod tests { assert!(error.to_string().contains("already exists")); } + #[test] + fn primary_config_write_failure_does_not_create_local_side_effects() { + let dir = tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::create_dir(dir.path().join("v8project.yaml")).expect("config path directory"); + + let error = execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: true, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect_err("directory output path cannot be written as file"); + + assert!(error.to_string().contains("failed to write config file")); + assert!(!dir.path().join("v8project.local.yaml").exists()); + assert!(!dir.path().join(".gitignore").exists()); + } + + #[test] + fn creates_empty_local_overlay_and_gitignore_entry() { + let dir = tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + + let result = execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + assert_eq!( + result.local_path, + dir.path() + .join("v8project.local.yaml") + .display() + .to_string() + ); + assert_eq!( + result.gitignore_path, + dir.path().join(".gitignore").display().to_string() + ); + let local_config = + std::fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local config"); + assert_eq!( + local_config, + format!("{}\n{{}}\n", super::LOCAL_CONFIG_SCHEMA_MODEL_LINE) + ); + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!(gitignore, "v8project.local.yaml\n"); + } + + #[test] + fn preserves_existing_local_overlay_and_gitignore_pattern() { + let dir = tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::write( + dir.path().join("v8project.local.yaml"), + "# yaml-language-server: $schema=https://old.example/schema.json\nworkPath: local-work\n", + ) + .expect("local config"); + std::fs::write( + dir.path().join(".gitignore"), + "# local state\n**/v8project.local.yaml\n", + ) + .expect("gitignore"); + + execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + let local_config = + std::fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local config"); + assert!(local_config.starts_with(super::LOCAL_CONFIG_SCHEMA_MODEL_LINE)); + assert!(local_config.contains("workPath: local-work")); + assert!(!local_config.contains("old.example")); + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!(gitignore, "# local state\n**/v8project.local.yaml\n"); + } + + #[test] + fn appends_gitignore_entry_when_existing_pattern_targets_only_nested_local_config() { + let dir = tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::write(dir.path().join(".gitignore"), "docs/v8project.local.yaml\n") + .expect("gitignore"); + + execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!( + gitignore, + "docs/v8project.local.yaml\nv8project.local.yaml\n" + ); + } + + #[test] + fn does_not_append_gitignore_entry_when_git_already_ignores_local_config() { + let dir = tempdir().expect("tempdir"); + init_git_repo(dir.path()); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::write(dir.path().join(".gitignore"), "*.local.yaml\n").expect("gitignore"); + + execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!(gitignore, "*.local.yaml\n"); + } + + #[test] + fn appends_gitignore_entry_when_existing_pattern_is_negated() { + let dir = tempdir().expect("tempdir"); + init_git_repo(dir.path()); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::write( + dir.path().join(".gitignore"), + "v8project.local.yaml\n!v8project.local.yaml\n", + ) + .expect("gitignore"); + + execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!( + gitignore, + "v8project.local.yaml\n!v8project.local.yaml\nv8project.local.yaml\n" + ); + } + + #[test] + fn does_not_create_nested_gitignore_when_root_gitignore_covers_output_override_local_config() { + let dir = tempdir().expect("tempdir"); + init_git_repo(dir.path()); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::write( + dir.path().join(".gitignore"), + "config/v8project.local.yaml\n", + ) + .expect("gitignore"); + + execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "config/v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + assert!(dir + .path() + .join("config") + .join("v8project.local.yaml") + .exists()); + assert!(!dir.path().join("config").join(".gitignore").exists()); + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!(gitignore, "config/v8project.local.yaml\n"); + } + #[test] fn extension_detection_uses_designer_xml_marker() { let dir = tempdir().expect("tempdir"); diff --git a/tests/cli_config_init.rs b/tests/cli_config_init.rs index 97b4a9f..1e20256 100644 --- a/tests/cli_config_init.rs +++ b/tests/cli_config_init.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use support::{temp_workspace, v8_runner_command}; const V8_EXTERNAL_OBJECTS_NATURE: &str = "com._1c.g5.v8.dt.core.V8ExternalObjectsNature"; +const LOCAL_CONFIG_SCHEMA_MODEL_LINE: &str = "# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json"; fn copy_dir_all(src: &Path, dst: &Path) { fs::create_dir_all(dst).expect("create dst"); @@ -96,6 +97,13 @@ fn config_init_creates_yaml_with_detected_designer_sources() { assert!(config.contains("name: 'SalesAddon'")); assert!(config.contains("type: EXTENSION")); assert!(String::from_utf8_lossy(&output.stdout).contains("Config written")); + let local_config = + fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local config"); + assert!(local_config.starts_with(LOCAL_CONFIG_SCHEMA_MODEL_LINE)); + serde_yaml::from_str::(&local_config) + .expect("generated local config remains YAML"); + let gitignore = fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert!(gitignore.lines().any(|line| line == "v8project.local.yaml")); } #[test] @@ -123,6 +131,17 @@ fn config_init_uses_json_envelope_and_output_override() { let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["command"], "config init"); + assert_eq!( + payload["data"]["local_path"], + dir.path() + .join("v8project.local.yaml") + .display() + .to_string() + ); + assert_eq!( + payload["data"]["gitignore_path"], + dir.path().join(".gitignore").display().to_string() + ); assert_eq!(payload["data"]["source_sets"][0]["path"], "."); assert_eq!(payload["data"]["source_sets"][0]["type"], "CONFIGURATION"); let config = fs::read_to_string(config_path).expect("config"); @@ -130,6 +149,27 @@ fn config_init_uses_json_envelope_and_output_override() { assert!(config.contains(" connection: 'File=/tmp/test-ib'")); } +#[test] +fn config_init_creates_local_overlay_next_to_output_override() { + let dir = temp_workspace(); + fs::write(dir.path().join("Configuration.xml"), "").expect("xml"); + + let output = v8_runner_command() + .current_dir(dir.path()) + .args(["config", "init", "--output", "config/v8project.yaml"]) + .output() + .expect("run command"); + + assert!(output.status.success()); + assert!(dir.path().join("config").join("v8project.yaml").exists()); + let local_config = fs::read_to_string(dir.path().join("config").join("v8project.local.yaml")) + .expect("local config"); + assert!(local_config.starts_with(LOCAL_CONFIG_SCHEMA_MODEL_LINE)); + let gitignore = + fs::read_to_string(dir.path().join("config").join(".gitignore")).expect("gitignore"); + assert!(gitignore.lines().any(|line| line == "v8project.local.yaml")); +} + #[test] fn config_init_rejects_global_config_shortcut_in_text_mode() { let dir = temp_workspace(); From 368da80c5e1b4926da5b64776f22347776fdafdd Mon Sep 17 00:00:00 2001 From: alkoleft Date: Mon, 11 May 2026 22:54:08 +0300 Subject: [PATCH 02/16] feat(config): remove public basePath setting - derive project base path from primary config directory - update config init, schemas, docs, fixtures, and tests --- SKILL/references/config-and-backends.md | 2 +- .../references/file-and-artifact-workflows.md | 2 +- SKILL/references/troubleshooting.md | 2 +- docs/CAPABILITIES.md | 2 +- docs/CONFIGURATION.md | 19 ++-- docs/DEEP_DIVE.md | 2 +- docs/schemas/v8project.schema.json | 6 +- examples/live-cli-designer.fixture.yaml | 1 - examples/v8project.yaml | 6 +- scripts/test/live-cli-designer.fixture.yaml | 1 - scripts/test/live-cli-fixture.sh | 18 +--- .../acceptance/real-environment-validation.md | 2 +- spec/architecture/invariants.md | 2 +- ...untime-state-po-source-set-pod-workpath.md | 4 +- ...vustoronney-konvertatsii-edt-i-designer.md | 6 +- .../decisions/0021-lokalnyy-overlay-config.md | 12 +-- src/change_detection/source_sets.rs | 4 +- src/config/loader.rs | 49 ++++------ src/config/model.rs | 2 +- src/config/schema.rs | 16 ++- src/config/validate.rs | 2 +- src/use_cases/artifacts.rs | 8 +- src/use_cases/config_init.rs | 97 +++++++++++++++---- src/use_cases/convert_sources.rs | 10 +- src/use_cases/dump_config.rs | 6 +- src/use_cases/dump_config/helpers.rs | 2 +- tests/cli_artifacts.rs | 7 +- tests/cli_bootstrap.rs | 27 +----- tests/cli_build.rs | 19 ++-- tests/cli_config_init.rs | 7 +- tests/cli_convert.rs | 11 +-- tests/cli_dump.rs | 10 +- tests/cli_extensions.rs | 7 +- tests/cli_init.rs | 15 ++- tests/cli_launch.rs | 17 ++-- tests/cli_load.rs | 7 +- tests/cli_syntax.rs | 9 +- tests/cli_test.rs | 22 ++--- tests/mcp_http.rs | 15 ++- tests/mcp_stdio.rs | 33 +++---- 40 files changed, 234 insertions(+), 255 deletions(-) diff --git a/SKILL/references/config-and-backends.md b/SKILL/references/config-and-backends.md index 7b5d075..f9d67c9 100644 --- a/SKILL/references/config-and-backends.md +++ b/SKILL/references/config-and-backends.md @@ -6,7 +6,6 @@ settings before CLI overrides. ## Fields To Check First -- `basePath`: root of 1C source files; defaults to the directory containing the primary config when omitted. - `workPath`: generated state, temp files, and workspace location. - `format`: `DESIGNER` or `EDT`. - `builder`: `DESIGNER` or `IBCMD`. @@ -36,6 +35,7 @@ settings before CLI overrides. ## Source-Set Notes `source-set.name` is the stable identity for ordering, diagnostics, runtime contexts, generated directories, and command selection. +Relative `source-set.path` values are resolved from the directory containing the primary `v8project.yaml`. Supported `source-set.type` values: diff --git a/SKILL/references/file-and-artifact-workflows.md b/SKILL/references/file-and-artifact-workflows.md index 477a344..5ee3217 100644 --- a/SKILL/references/file-and-artifact-workflows.md +++ b/SKILL/references/file-and-artifact-workflows.md @@ -47,7 +47,7 @@ It is not a dump alias: - it does not use `builder`; - direction is derived from configured `format`; - without `--output`, results are published under `workPath/convert/out///`; -- `--output` is a target root and mirrors `source-set.path` relative to `basePath`. +- `--output` is a target root and mirrors `source-set.path` relative to the primary config directory. `convert` is a CLI file workflow and does not run through an infobase. diff --git a/SKILL/references/troubleshooting.md b/SKILL/references/troubleshooting.md index f584b7d..0a89c93 100644 --- a/SKILL/references/troubleshooting.md +++ b/SKILL/references/troubleshooting.md @@ -14,7 +14,7 @@ Inspect `v8project.yaml` fields that affect the failing command: - `format` - `builder` - `connection` -- `basePath` +- primary config directory - `workPath` - `source-set` - `tools.platform` diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 6bc9ccb..ad61ec1 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -201,7 +201,7 @@ v8-runner convert [--source-set ] [--output ] - Работает от текущего `v8project.yaml`, а не по arbitrary source/target paths. - Направление определяется только из `format`. - Без `--output` публикует результат под `workPath/convert/out///`. -- `--output` задаёт только target root и зеркалит `source-set.path` относительно `basePath`. +- `--output` задаёт только target root и зеркалит `source-set.path` относительно каталога primary config. - Публикация остаётся staged full replacement с overlap guardrails. ### `load` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e2ae8d0..81aca77 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -103,7 +103,8 @@ Schema version равна версии приложения из `Cargo.toml` и `v8project.yaml` использует не один стиль на весь документ. Это текущий loader contract, и docs ниже повторяют именно literal YAML keys. -- top-level app keys: `basePath`, `workPath`, `source-set`; +- top-level app keys: `workPath`, `execution_timeout`, `format`, `builder`, `infobase`, + `source-set`, `build`, `tools`, `mcp`, `tests`; - `build` использует `partialLoadThreshold`; - `mcp.*` и `tests.*` используют `snake_case`; - canonical key для EDT tool section: `tools.edt_cli`; @@ -118,8 +119,6 @@ Schema version равна версии приложения из `Cargo.toml` и ## Канонический пример ```yaml -# basePath можно опустить, тогда он равен каталогу v8project.yaml. -basePath: /path/to/project workPath: build execution_timeout: 300000 format: EDT @@ -252,14 +251,6 @@ tests: ## Обязательный контракт -### `basePath` - -- Тип: путь -- Обязателен: нет -- По умолчанию: каталог primary `v8project.yaml` - -Корень исходников проекта. Должен существовать и быть каталогом. - ### `workPath` - Тип: путь @@ -350,6 +341,8 @@ Credentials самой информационной базы. - `type` - `path` +`path` задаётся относительно каталога primary `v8project.yaml`, если он не абсолютный. + `type` поддерживает только: - `CONFIGURATION` @@ -418,8 +411,8 @@ runtime identity и не добавляет отдельное поле конф ведущий `@`, если он указан в `profiles..filter_tags`, `profiles..ignore_tags`, `--filter-tag` или `--ignore-tag`. -При генерации runtime `VAParams` runner добавляет `WorkspaceRoot` со значением `basePath`, -если это поле отсутствует или равно `null` в `tests.va.params_path`. +При генерации runtime `VAParams` runner добавляет `WorkspaceRoot` со значением каталога primary +`v8project.yaml`, если это поле отсутствует или равно `null` в `tests.va.params_path`. Для Vanessa Automation обязательны: diff --git a/docs/DEEP_DIVE.md b/docs/DEEP_DIVE.md index e9c71fc..9295445 100644 --- a/docs/DEEP_DIVE.md +++ b/docs/DEEP_DIVE.md @@ -116,7 +116,7 @@ execution model для CLI и MCP. `workPath` является корнем runtime state. -- Логи, temp files, generated outputs и persisted snapshots не должны расползаться по `basePath`. +- Логи, temp files, generated outputs и persisted snapshots не должны расползаться по каталогу primary config. - Public CLI/MCP команды, работающие с runtime state под `workPath`, должны брать workspace lock. - Workspace lock сериализует доступ к конкретному runtime root, но не заменяет admission limits и не делает multi-step orchestration fully atomic. diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index d4cf607..4882b44 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -317,7 +317,7 @@ "type": "string" }, "path": { - "description": "Source path relative to `basePath` or an EDT project path.", + "description": "Source path relative to the primary config directory or an EDT project path.", "type": "string" }, "type": { @@ -597,10 +597,6 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { - "basePath": { - "description": "Root directory for project sources; defaults to the directory containing `v8project.yaml`.", - "type": "string" - }, "build": { "$ref": "#/$defs/BuildSchema", "description": "Build pipeline settings." diff --git a/examples/live-cli-designer.fixture.yaml b/examples/live-cli-designer.fixture.yaml index 6cbf77d..3ddf535 100644 --- a/examples/live-cli-designer.fixture.yaml +++ b/examples/live-cli-designer.fixture.yaml @@ -1,7 +1,6 @@ # Fixture-based live smoke config for scripts/test/live-cli-fixture.sh # Copy this file, adjust the absolute paths, and export it as V8TR_DESIGNER_REAL_CONFIG. -basePath: /abs/path/to/repo/tests/fixtures/designer workPath: /abs/path/to/repo/target/manual-tests/live-cli-fixture/work format: DESIGNER builder: DESIGNER diff --git a/examples/v8project.yaml b/examples/v8project.yaml index 9154979..f41e96c 100644 --- a/examples/v8project.yaml +++ b/examples/v8project.yaml @@ -1,10 +1,6 @@ # Example configuration for v8-runner # Copy to v8project.yaml and adjust paths for your project -# Root path of the project sources (Designer format). -# Optional: when omitted, v8-runner uses the directory containing v8project.yaml. -basePath: /path/to/project/sources - # Working directory for temp files and hash storages workPath: build @@ -22,7 +18,7 @@ infobase: source-set: - name: main type: CONFIGURATION - path: . # relative to basePath + path: . # relative to v8project.yaml # Uncomment to add an extension: # - name: my-extension diff --git a/scripts/test/live-cli-designer.fixture.yaml b/scripts/test/live-cli-designer.fixture.yaml index 55b6f0a..e79839b 100644 --- a/scripts/test/live-cli-designer.fixture.yaml +++ b/scripts/test/live-cli-designer.fixture.yaml @@ -1,7 +1,6 @@ # Default live smoke config for scripts/test/live-cli-fixture.sh. # The script resolves __ROOT_DIR__ and __OUTPUT_ROOT__ into an isolated workspace. -basePath: __OUTPUT_ROOT__/workspace/basePath workPath: __OUTPUT_ROOT__/work format: DESIGNER builder: DESIGNER diff --git a/scripts/test/live-cli-fixture.sh b/scripts/test/live-cli-fixture.sh index 0420c3d..6eb978c 100755 --- a/scripts/test/live-cli-fixture.sh +++ b/scripts/test/live-cli-fixture.sh @@ -360,17 +360,6 @@ replacements = { for needle, replacement in replacements.items(): text = text.replace(needle, replacement) -if re.search(r"^\s*basePath:\s*.*$", text, re.MULTILINE): - text = re.sub( - r"^\s*basePath:\s*.*$", - f"basePath: {work_base_path.as_posix()}", - text, - count=1, - flags=re.MULTILINE, - ) -else: - raise SystemExit("live config must define basePath") - target.write_text(text, encoding="utf-8") PY } @@ -547,8 +536,8 @@ EXTERNAL_PROCESSOR_SOURCE_SET_PATH="${SOURCE_SET_PATH_BY_TYPE[EXTERNAL_DATA_PROC EXTERNAL_REPORT_SOURCE_SET_NAME="${SOURCE_SET_NAME_BY_TYPE[EXTERNAL_REPORTS]:-}" EXTERNAL_REPORT_SOURCE_SET_PATH="${SOURCE_SET_PATH_BY_TYPE[EXTERNAL_REPORTS]:-}" -WORK_BASE_PATH="$OUTPUT_ROOT/workspace/basePath" -WORK_CONFIG_PATH="$OUTPUT_ROOT/json/live-designer.config.yaml" +WORK_BASE_PATH="$OUTPUT_ROOT/workspace/project-root" +WORK_CONFIG_PATH="$WORK_BASE_PATH/v8project.yaml" if [[ ! -d "$FIXTURE_BASE_PATH" ]]; then die "Fixture source directory not found: $FIXTURE_BASE_PATH" @@ -564,7 +553,6 @@ mkdir -p \ "$WORK_BASE_PATH" \ "$OUTPUT_ROOT/artifacts/external-processor" \ "$OUTPUT_ROOT/artifacts/external-report" \ - "$OUTPUT_ROOT/json" \ "$OUTPUT_ROOT/launch" cp -R "$FIXTURE_BASE_PATH/." "$WORK_BASE_PATH/" @@ -575,7 +563,7 @@ DESIGNER_CONFIG_PATH="$WORK_CONFIG_PATH" for source_set_type in "${required_types[@]}"; do source_set_path="${SOURCE_SET_PATH_BY_TYPE[$source_set_type]}" if [[ ! -d "$WORK_BASE_PATH/$source_set_path" ]]; then - die "Configured source-set path does not exist under fixture basePath: $source_set_path" + die "Configured source-set path does not exist under fixture project root: $source_set_path" fi done diff --git a/spec/acceptance/real-environment-validation.md b/spec/acceptance/real-environment-validation.md index 6446a70..5d96939 100644 --- a/spec/acceptance/real-environment-validation.md +++ b/spec/acceptance/real-environment-validation.md @@ -126,7 +126,7 @@ bash scripts/test/live-cli-fixture.sh - `format: DESIGNER` - `builder: DESIGNER` - файловое подключение `File=...` или raw `/F ...` -- `basePath`, резолвящийся в `tests/fixtures/designer` +- primary config directory, резолвящийся в `tests/fixtures/designer` - source-set'ы для `configuration`, `extension`, `external-processor`, `external-report` - заданный `tools.platform.path` или внешний override `V8TR_PLATFORM_PATH` diff --git a/spec/architecture/invariants.md b/spec/architecture/invariants.md index fcdd37d..a93e422 100644 --- a/spec/architecture/invariants.md +++ b/spec/architecture/invariants.md @@ -39,7 +39,7 @@ 12. Unsupported или unsafe config combinations должны отклоняться на validation boundary до вызова platform DSL. 13. Новый public config field, `source-set` type или `infobase` subtree требует typed model, validation, `config init`/examples/docs sync и regression tests по checklist из `spec/architecture/change-checklist.md`. 14. `v8project.local.yaml` является optional local overlay рядом с primary config, применяется после `v8project.yaml` и до CLI overrides, не является самостоятельным `--config` entrypoint и не должен менять `source-set`, `format` или `builder`. -15. Если `basePath` отсутствует в итоговом YAML после overlay merge, он считается равным каталогу primary config. +15. `basePath` не является public key в `v8project.yaml`; внутренний project base path считается равным каталогу primary config. 16. Tool extensions, включая `tools.client_mcp.extension`, не являются project `source-set`; их подготовка выполняется через общий механизм подготовки расширений на стадии `build`, а не на стадии `launch`. См. [ADR-0017](../decisions/0017-v8project-yaml-source-set-kak-glavnyy-konfiguratsionnyy-kontrakt.md), [ADR-0018](../decisions/0018-perenesti-kontrakt-informatsionnoy-bazy-v-infobase.md), [ADR-0019](../decisions/0019-sozdavat-servernuyu-infobazu-cherez-ibcmd-pri-init-pri-otsutstvii.md), [ADR-0021](../decisions/0021-lokalnyy-overlay-config.md) и [ADR-0022](../decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md). diff --git a/spec/decisions/0002-izolirovat-runtime-state-po-source-set-pod-workpath.md b/spec/decisions/0002-izolirovat-runtime-state-po-source-set-pod-workpath.md index f628fc3..7b7ce32 100644 --- a/spec/decisions/0002-izolirovat-runtime-state-po-source-set-pod-workpath.md +++ b/spec/decisions/0002-izolirovat-runtime-state-po-source-set-pod-workpath.md @@ -48,7 +48,7 @@ EDT source-set Правила: -1. `edt-` хранит состояние основных EDT-исходников из `basePath/source-set.path`. +1. `edt-` хранит состояние основных EDT-исходников из project root + `source-set.path`. 2. `edt-` используется только для решения, нужна ли конвертация/export EDT source-set в Designer-формат. 3. `designer-` хранит состояние generated Designer-файлов под `workPath/designer/`. 4. `designer-` используется для решения, какие Designer-файлы грузить: partial или full. @@ -67,7 +67,7 @@ EDT source-set ## Неграницы (Non-goals) 1. Не вводить единый глобальный hash storage на весь проект. -2. Не писать runtime-артефакты в `basePath` или каталоги пользовательских исходников. +2. Не писать runtime-артефакты в project root или каталоги пользовательских исходников. 3. Не считать `workPath/designer/` пользовательскими исходниками. 4. Не обещать атомарность `build` по нескольким `source-set`. 5. Не менять публичную YAML-модель `source-set` без отдельного решения. diff --git a/spec/decisions/0020-dobavit-cli-only-convert-dlya-dvustoronney-konvertatsii-edt-i-designer.md b/spec/decisions/0020-dobavit-cli-only-convert-dlya-dvustoronney-konvertatsii-edt-i-designer.md index c29c378..b5433c7 100644 --- a/spec/decisions/0020-dobavit-cli-only-convert-dlya-dvustoronney-konvertatsii-edt-i-designer.md +++ b/spec/decisions/0020-dobavit-cli-only-convert-dlya-dvustoronney-konvertatsii-edt-i-designer.md @@ -53,13 +53,13 @@ 8. Флаги `--source`, `--target`, `--version`, `--base-project-name` и `--build` не являются частью целевого public contract. 9. Внутренние EDT import/export параметры должны выводиться из `config.format`, `source-set` semantics, project metadata и tool discovery/config hints, а не требовать явного user-facing флага. 10. Без `--output` результат `convert` публикуется в owned generated directories под `workPath/convert/out///`. -11. С `--output ` команда публикует выбранные `source-set` под заданным target root, зеркаля logical source-set path относительно `basePath`, например `configuration`, `extension`, `external/processor`. +11. С `--output ` команда публикует выбранные `source-set` под заданным target root, зеркаля logical source-set path относительно каталога primary config, например `configuration`, `extension`, `external/processor`. 12. `--output` задаёт только target root, а не произвольные пары `source`/`target`; direction и список source-set по-прежнему выводятся из `v8project.yaml`. 13. Реальная EDT execution должна использовать тот же supported execution model, что и остальные EDT-сценарии: one-shot или shared interactive в зависимости от `tools.edt_cli.interactive_mode`. 14. Runtime state EDT для команды должен жить в отдельном рабочем каталоге `workPath/convert/edt-workspace`, а не переиспользовать `workPath/edt-workspace` из `init` и других EDT-сценариев. 15. Как и другие public команды с runtime state под `workPath`, `convert` должен брать workspace lock на adapter boundary по ADR-0011. 16. Validation, не требующая владения `workPath`, может выполняться до захвата lock, чтобы пользователь получал deterministic validation error раньше workspace-conflict error. -17. Публикация результата должна использовать full-replacement staging/backup contract по ADR-0015 для каждого resolved target; `basePath`, исходные каталоги проекта и пересекающиеся target paths не являются допустимыми publish target. +17. Публикация результата должна использовать full-replacement staging/backup contract по ADR-0015 для каждого resolved target; каталог primary config, исходные каталоги проекта и пересекающиеся target paths не являются допустимыми publish target. 18. Для `DESIGNER -> EDT` staging path не должен протекать в имена сгенерированных EDT-проектов: target project directory выбирается стабильно из logical source-set path/name до атомарной публикации. 19. Историческая path-based реализация `convert` была transition state; текущий public contract считается repo-aware только через `v8project.yaml`, `source-set` scope и optional target-root `--output`. 20. Это решение не расширяет контракт `dump`: реализованный `dump format=EDT` остаётся отдельной командной семантикой "ИБ -> файлы", а не thin alias поверх `convert`. @@ -124,7 +124,7 @@ - stable generated EDT project names; - validation-before-lock и busy workspace conflict; - one-shot и shared interactive execution paths; - - запрет destructive overlap по отношению к `basePath`. + - запрет destructive overlap по отношению к каталогу primary config. 7. После фактической реализации синхронизировать: - `README.md`; - `docs/CAPABILITIES.md`; diff --git a/spec/decisions/0021-lokalnyy-overlay-config.md b/spec/decisions/0021-lokalnyy-overlay-config.md index cfc737e..8286b48 100644 --- a/spec/decisions/0021-lokalnyy-overlay-config.md +++ b/spec/decisions/0021-lokalnyy-overlay-config.md @@ -45,7 +45,7 @@ Loader строит итоговую конфигурацию так: 3. List значения заменяются целиком. 4. `null` разрешён только для optional fields и означает явный сброс значения. 5. Относительные пути из local overlay резолвятся относительно каталога основного `v8project.yaml`. -6. Если `basePath` не задан в итоговом config, он считается равным каталогу основного `v8project.yaml`. +6. Внутренний project base path считается равным каталогу основного `v8project.yaml`; YAML-ключ `basePath` не является public contract. ### Supported local overlay scope @@ -74,13 +74,13 @@ Local overlay не должен менять project identity: 1. Типовой запуск остаётся коротким и не требует локальных флагов. 2. Общий `v8project.yaml` остаётся project truth, а локальные пути и credentials уходят в gitignored `v8project.local.yaml`. -3. `basePath` перестаёт быть обязательным в типовом config и по умолчанию совпадает с каталогом основного config. +3. `basePath` удалён из public config surface; внутренний `AppConfig.base_path` по умолчанию совпадает с каталогом основного config. 4. Реализация должна синхронизировать typed config model, loader, validation, docs, examples and tests. ## План реализации 1. `src/config/model.rs`: - - сделать `basePath` optional на YAML boundary или ввести raw config model; + - удалить `basePath` с YAML boundary; - сохранить итоговый `AppConfig.base_path` как resolved `PathBuf`. 2. `src/config/loader.rs`: - читать `v8project.local.yaml` рядом с primary config, если файл существует; @@ -88,18 +88,18 @@ Local overlay не должен менять project identity: - отклонять local overlay keys `source-set`, `format`, `builder`; - резолвить overlay paths относительно primary config directory. 3. `docs/CONFIGURATION.md`, examples: - - описать local overlay и default `basePath`; + - описать local overlay и project root от каталога primary config; - указать, что `v8project.local.yaml` должен быть gitignored. 4. Tests: - покрыть automatic overlay discovery; - overlay merge rules; - forbidden local keys; - - `basePath` default; + - default внутреннего `AppConfig.base_path`; - precedence `project -> local -> CLI override`. ## Верификация - [x] `v8-runner build` автоматически применяет `v8project.local.yaml` без дополнительных CLI-флагов. - [x] `source-set`, `format` и `builder` в `v8project.local.yaml` отклоняются как unsupported local overlay keys. -- [x] Отсутствующий `basePath` резолвится в каталог основного `v8project.yaml`. +- [x] Внутренний `AppConfig.base_path` резолвится в каталог основного `v8project.yaml`. - [x] `--workdir` остаётся сильнее local overlay. diff --git a/src/change_detection/source_sets.rs b/src/change_detection/source_sets.rs index 6378ee0..6adb256 100644 --- a/src/change_detection/source_sets.rs +++ b/src/change_detection/source_sets.rs @@ -6,7 +6,7 @@ use crate::domain::source_set::SourceSetContext; /// Builds the list of [`SourceSetContext`] instances for the given config. /// -/// - `DESIGNER` format: one context per source-set, rooted at `basePath/ss.path`. +/// - `DESIGNER` format: one context per source-set, rooted at project base path + `ss.path`. /// - `EDT` format (Wave 2): two contexts per source-set — the original EDT path /// and a generated Designer copy under `workPath/designer//`. pub struct SourceSetsService<'a> { @@ -20,7 +20,7 @@ impl<'a> SourceSetsService<'a> { /// Return all Designer-format contexts that should be scanned and built. /// - /// In `DESIGNER` mode this is simply each source-set resolved against `basePath`. + /// In `DESIGNER` mode this is simply each source-set resolved against the project base path. /// In `EDT` mode (Wave 2) this returns the generated Designer copies in /// `workPath/designer`. pub fn designer_contexts(&self) -> Vec { diff --git a/src/config/loader.rs b/src/config/loader.rs index e5335d8..0d65feb 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -65,10 +65,10 @@ pub fn load_config( merge_yaml_values(&mut root, overlay); } - default_base_path_to_config_dir(&mut root, config_dir)?; reject_legacy_config_keys(&root)?; validate_main_config_schema_boundary(root.clone()) .map_err(|error| ConfigLoadError::UnsupportedShape(error.to_string()))?; + default_base_path_to_config_dir(&mut root, config_dir)?; let mut config: AppConfig = serde_yaml::from_value(root)?; normalize_config_paths(&mut config, config_dir); @@ -617,8 +617,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -643,8 +642,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n purpose: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n purpose: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -671,8 +669,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nbuild:\n partialLoadThreshold: 7\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nbuild:\n partialLoadThreshold: 7\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -694,8 +691,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntests:\n execution_timeout_seconds: 17\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntests:\n execution_timeout_seconds: 17\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -717,8 +713,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nexecution_timeout: 4321\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nexecution_timeout: 4321\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -776,8 +771,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n va:\n epf_path: va/runner.epf\ntests:\n va:\n params_path: va/params.json\n profile: smoke\n profiles:\n smoke:\n feature_path: features\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n va:\n epf_path: va/runner.epf\ntests:\n va:\n params_path: va/params.json\n profile: smoke\n profiles:\n smoke:\n feature_path: features\nsource-set:\n - name: main\n type: CONFIGURATION\n path: ../base/src\n", work.display() ), ) @@ -814,13 +808,13 @@ mod tests { let config_path = config_dir.join("v8project.yaml"); std::fs::write( &config_path, - "basePath: sources\nworkPath: build\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=build/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", + "workPath: build\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=build/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: sources\n", ) .expect("write config"); let config = load_config(config_path.to_str(), None).expect("load config"); - assert_eq!(config.base_path, base); + assert_eq!(config.base_path, config_dir); assert_eq!(config.work_path, config_dir.join("build")); assert_eq!( config.infobase.connection, @@ -837,7 +831,7 @@ mod tests { let config_path = config_dir.join("v8project.yaml"); std::fs::write( &config_path, - "basePath: sources\nworkPath: build\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: \"Srvr=cluster:1541;Ref=demo\"\n dbms:\n kind: PostgreSQL\n server: localhost\n name: demo\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", + "workPath: build\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: \"Srvr=cluster:1541;Ref=demo\"\n dbms:\n kind: PostgreSQL\n server: localhost\n name: demo\nsource-set:\n - name: main\n type: CONFIGURATION\n path: sources\n", ) .expect("write config"); @@ -951,7 +945,7 @@ mod tests { let config_path = config_dir.join("v8project.yaml"); std::fs::write( &config_path, - "basePath: sources\nworkPath: build\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: '/F \"build/my ib\"'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", + "workPath: build\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: '/F \"build/my ib\"'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: sources\n", ) .expect("write config"); @@ -981,8 +975,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nmcp:\n http:\n bind_address: 127.0.0.1:4000\n path: /custom-mcp\n stateful_sessions: false\n max_sessions: 12\n idle_ttl_secs: 45\n execution:\n max_concurrent_calls: 3\n shutdown_grace_period_secs: 9\ntools:\n client_mcp:\n port: 9874\n extension:\n name: client_mcp\n source:\n path: exts/client-mcp\n format: DESIGNER\n enterprise:\n additional-launch-keys:\n - /TESTMANAGER\n edt_cli:\n interactive-mode: true\n startup_timeout_ms: 1234\n command_timeout_ms: 5678\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nmcp:\n http:\n bind_address: 127.0.0.1:4000\n path: /custom-mcp\n stateful_sessions: false\n max_sessions: 12\n idle_ttl_secs: 45\n execution:\n max_concurrent_calls: 3\n shutdown_grace_period_secs: 9\ntools:\n client_mcp:\n port: 9874\n extension:\n name: client_mcp\n source:\n path: exts/client-mcp\n format: DESIGNER\n enterprise:\n additional-launch-keys:\n - /TESTMANAGER\n edt_cli:\n interactive-mode: true\n startup_timeout_ms: 1234\n command_timeout_ms: 5678\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1035,8 +1028,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n client_mcp:\n extension:\n{extension_body}source-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n client_mcp:\n extension:\n{extension_body}source-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1066,8 +1058,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n enterprise:\n additional-launch-keys:\n - /TESTMANAGER\n - /TCUser\n - ci-user\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n enterprise:\n additional-launch-keys:\n - /TESTMANAGER\n - /TCUser\n - ci-user\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1121,8 +1112,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\n{extra_yaml}source-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\n{extra_yaml}source-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1144,8 +1134,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1175,8 +1164,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n edt_cli:\n startup_timeout_ms: 2222\n command_timeout_ms: 3333\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n edt_cli:\n startup_timeout_ms: 2222\n command_timeout_ms: 3333\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1199,8 +1187,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n platform:\n version: 8.3.27.1859\n edt_cli:\n path: 1c-edt-2025.2.3\n version: 1c-edt-2025.2.3\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n platform:\n version: 8.3.27.1859\n edt_cli:\n path: 1c-edt-2025.2.3\n version: 1c-edt-2025.2.3\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) diff --git a/src/config/model.rs b/src/config/model.rs index f63b811..c986256 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -202,7 +202,7 @@ pub struct SourceSetConfig { #[serde(rename = "type")] pub purpose: SourceSetPurpose, - /// Path relative to basePath (for DESIGNER) or EDT project path + /// Path relative to the project base path (for DESIGNER) or EDT project path. pub path: PathBuf, } diff --git a/src/config/schema.rs b/src/config/schema.rs index f974cc6..b27f74f 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -307,14 +307,6 @@ where #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct MainConfigSchema { - /// Root directory for project sources; defaults to the directory containing `v8project.yaml`. - #[serde( - default, - deserialize_with = "deserialize_non_null_optional", - skip_serializing_if = "Option::is_none" - )] - #[schemars(with = "PathBuf")] - base_path: Option, /// Working directory for generated state, logs, temporary files, and hash storages. work_path: PathBuf, /// Global execution budget for public CLI and MCP commands in milliseconds. @@ -509,7 +501,7 @@ struct SourceSetSchema { /// Source-set type: configuration, extension, external data processors, or external reports. #[serde(rename = "type")] purpose: SourceSetPurposeSchema, - /// Source path relative to `basePath` or an EDT project path. + /// Source path relative to the primary config directory or an EDT project path. path: PathBuf, } @@ -1047,6 +1039,7 @@ mod tests { use std::path::Path; const REMOVED_SCHEMA_ALIAS_PROPERTIES: &[&str] = &[ + "basePath", "executionTimeout", "execution_timeout_ms", "edt-cli", @@ -1076,7 +1069,6 @@ mod tests { #[test] fn generated_schemas_include_user_facing_field_descriptions() { let main_schema = main_config_schema_json(); - assert_property_description_contains(&main_schema, &[], "basePath", "Root directory"); assert_property_description_contains(&main_schema, &[], "workPath", "Working directory"); assert_property_description_contains( &main_schema, @@ -1478,6 +1470,10 @@ mod tests { "{}toolz: {{}}\n", minimal_project_config_without_base_path() ), + format!( + "{}basePath: /tmp/project\n", + minimal_project_config_without_base_path() + ), format!( "{}tools:\n platform:\n typo: value\n", minimal_project_config_without_base_path() diff --git a/src/config/validate.rs b/src/config/validate.rs index b03dafa..be37fc7 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -17,7 +17,7 @@ pub enum ConfigValidationError { #[error("{0}")] InvalidYamlRoot(String), - #[error("basePath does not exist or is not a directory: {0}")] + #[error("project base path does not exist or is not a directory: {0}")] BasePathInvalid(String), #[error("workPath could not be created: {0}")] diff --git a/src/use_cases/artifacts.rs b/src/use_cases/artifacts.rs index a37a9cb..db4cfff 100644 --- a/src/use_cases/artifacts.rs +++ b/src/use_cases/artifacts.rs @@ -768,8 +768,10 @@ fn resolve_target( let canonical_output_path = nearest_existing_canonical_path(&output_path).map_err(|error| { AppError::Runtime(format!("failed to canonicalize output path: {error}")) })?; - let canonical_base_path = nearest_existing_canonical_path(&config.base_path) - .map_err(|error| AppError::Runtime(format!("failed to canonicalize basePath: {error}")))?; + let canonical_base_path = + nearest_existing_canonical_path(&config.base_path).map_err(|error| { + AppError::Runtime(format!("failed to canonicalize project base path: {error}")) + })?; let canonical_work_path = nearest_existing_canonical_path(&config.work_path) .map_err(|error| AppError::Runtime(format!("failed to canonicalize workPath: {error}")))?; let target_identity = stable_path_identity(&canonical_output_path); @@ -886,7 +888,7 @@ fn validate_publish_target(resolved: &ResolvedArtifactsTarget) -> Result<(), App } if resolved.canonical_output_path == resolved.canonical_base_path { return Err(AppError::Validation( - "artifacts output must not equal basePath".to_owned(), + "artifacts output must not equal project base path".to_owned(), )); } if resolved.canonical_output_path == resolved.canonical_work_path { diff --git a/src/use_cases/config_init.rs b/src/use_cases/config_init.rs index a8da4d7..3ddcd3d 100644 --- a/src/use_cases/config_init.rs +++ b/src/use_cases/config_init.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; use std::time::Instant; use crate::config::schema::main_config_schema_url; @@ -61,14 +62,23 @@ pub fn execute(request: &ConfigInitRequest) -> Result Result Vec { + source_sets + .iter() + .map(|source_set| ConfigInitSourceSet { + name: source_set.name.clone(), + source_type: source_set.source_type.clone(), + path: relative_path(config_dir, &project_dir.join(&source_set.path)), + }) + .collect() +} + fn source_set_name( project_dir: &Path, source: &DetectedSource, @@ -768,15 +786,56 @@ fn deduplicate_names(source_sets: &mut [ConfigInitSourceSet]) -> Result<(), AppE } fn relative_path(root: &Path, path: &Path) -> String { - path.strip_prefix(root) + if let Some(relative) = path + .strip_prefix(root) .ok() .filter(|relative| !relative.as_os_str().is_empty()) - .map(|relative| relative.display().to_string()) - .unwrap_or_else(|| ".".to_owned()) + { + return relative.display().to_string(); + } + + let root_components = normalized_components(root); + let path_components = normalized_components(path); + let common_len = root_components + .iter() + .zip(path_components.iter()) + .take_while(|(left, right)| left == right) + .count(); + + let mut relative = PathBuf::new(); + for _ in common_len..root_components.len() { + relative.push(".."); + } + for component in &path_components[common_len..] { + relative.push(component); + } + + if relative.as_os_str().is_empty() { + ".".to_owned() + } else { + relative.display().to_string() + } +} + +fn normalized_components(path: &Path) -> Vec { + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => components.push(prefix.as_os_str().to_os_string()), + Component::RootDir | Component::CurDir => {} + Component::ParentDir => match components.last() { + Some(last) if last != ".." => { + components.pop(); + } + _ => components.push(OsString::from("..")), + }, + Component::Normal(part) => components.push(part.to_os_string()), + } + } + components } fn render_config( - project_dir: &Path, connection: Option<&str>, format: ConfigFormatRequest, builder: ConfigBuilderRequest, @@ -790,10 +849,6 @@ fn render_config( main_config_schema_url() )); yaml.push_str("# Generated by v8-runner config init\n"); - yaml.push_str(&format!( - "basePath: '{}'\n", - escape_yaml(&project_dir.display().to_string()) - )); yaml.push_str("workPath: 'build'\n"); yaml.push_str("execution_timeout: 300000\n"); yaml.push_str(&format!("format: {}\n", format.as_yaml())); diff --git a/src/use_cases/convert_sources.rs b/src/use_cases/convert_sources.rs index 6378cff..d22d34c 100644 --- a/src/use_cases/convert_sources.rs +++ b/src/use_cases/convert_sources.rs @@ -772,13 +772,15 @@ fn validate_explicit_convert_target_roots( target_path: &Path, canonical_target_path: &Path, ) -> Result<(), AppError> { - let canonical_base_path = nearest_existing_canonical_path(&config.base_path) - .map_err(|error| AppError::Runtime(format!("failed to canonicalize basePath: {error}")))?; + let canonical_base_path = + nearest_existing_canonical_path(&config.base_path).map_err(|error| { + AppError::Runtime(format!("failed to canonicalize project base path: {error}")) + })?; if canonical_target_path == canonical_base_path || canonical_target_path.starts_with(&canonical_base_path) { return Err(AppError::Validation(format!( - "convert --output target must not be inside basePath: basePath={}, target={}", + "convert --output target must not be inside project base path: project_base_path={}, target={}", config.base_path.display(), target_path.display() ))); @@ -1442,7 +1444,7 @@ fn source_set_output_relative_path( let relative = if raw_path.is_absolute() { raw_path.strip_prefix(&config.base_path).map_err(|_| { AppError::Validation(format!( - "convert --output requires source-set '{}' path to be relative to basePath", + "convert --output requires source-set '{}' path to be relative to project base path", source_set.name )) })? diff --git a/src/use_cases/dump_config.rs b/src/use_cases/dump_config.rs index fc073e3..890a470 100644 --- a/src/use_cases/dump_config.rs +++ b/src/use_cases/dump_config.rs @@ -908,8 +908,10 @@ fn resolve_target(config: &AppConfig, args: &DumpArgs) -> Result (tempfile::TempDir, PathBuf, PathBuf) { fs::create_dir_all(base_path.join("main")).expect("main"); fs::create_dir_all(&work_path).expect("work"); write_designer_script(&binary_path, fail); - write_config(&config_path, &base_path, &work_path, &binary_path); + write_config(&config_path, &work_path, &binary_path); (dir, config_path, base_path) } diff --git a/tests/cli_bootstrap.rs b/tests/cli_bootstrap.rs index fc9be54..8f29b56 100644 --- a/tests/cli_bootstrap.rs +++ b/tests/cli_bootstrap.rs @@ -16,8 +16,7 @@ fn write_minimal_config(dir: &Path) -> PathBuf { fs::write( &config_path, format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\n", work_path.display() ), ) @@ -263,20 +262,7 @@ fn artifacts_pre_dispatch_validation_in_json_mode_keeps_command_identity() { #[test] fn mcp_rejects_clean_before_execution_flag() { let dir = temp_workspace(); - let config_path = dir.path().join("v8project.yaml"); - let base_path = dir.path().join("project"); - let work_path = dir.path().join("work"); - fs::create_dir_all(&base_path).expect("base"); - fs::create_dir_all(&work_path).expect("work"); - fs::write( - &config_path, - format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", - base_path.display(), - work_path.display() - ), - ) - .expect("config"); + let config_path = write_minimal_config(dir.path()); let output = v8_runner_command() .args([ @@ -308,8 +294,7 @@ fn legacy_top_level_connection_is_rejected_in_json_mode() { fs::write( &config_path, format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\nconnection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\nconnection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\n", work_path.display() ), ) @@ -347,8 +332,7 @@ fn legacy_top_level_credentials_is_rejected_in_json_mode() { fs::write( &config_path, format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ncredentials:\n user: Admin\n password: secret\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ncredentials:\n user: Admin\n password: secret\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\n", work_path.display() ), ) @@ -386,8 +370,7 @@ fn top_level_execution_timeout_seconds_is_rejected_in_json_mode() { fs::write( &config_path, format!( - "basePath: '{}'\nworkPath: '{}'\nexecution_timeout_seconds: 300\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", - base_path.display(), + "workPath: '{}'\nexecution_timeout_seconds: 300\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\n", work_path.display() ), ) diff --git a/tests/cli_build.rs b/tests/cli_build.rs index 333b6be..c0409f8 100644 --- a/tests/cli_build.rs +++ b/tests/cli_build.rs @@ -124,15 +124,14 @@ fn write_config_with_builder( fn write_config_with_builder_and_infobase( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, builder: &str, infobase_yaml: &str, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: {}\ninfobase:\n{}build:\n partialLoadThreshold: 20\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\n - name: ext\n type: EXTENSION\n path: ext\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: {}\ninfobase:\n{}build:\n partialLoadThreshold: 20\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\n - name: ext\n type: EXTENSION\n path: project/ext\ntools:\n platform:\n path: '{}'\n", work_path.display(), builder, infobase_yaml, @@ -307,8 +306,7 @@ fn setup_edt_ibcmd_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) { write_edt_script(&edt_cli_path, &edt_calls_log); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: IBCMD\ninfobase:\n connection: 'File=/tmp/ib'\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: IBCMD\ninfobase:\n connection: 'File=/tmp/ib'\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: project/configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", work_path.display(), ibcmd_path.display(), edt_cli_path.display(), @@ -368,8 +366,7 @@ fn setup_edt_extension_project() -> (tempfile::TempDir, PathBuf, PathBuf) { write_edt_script(&edt_cli_path, &edt_calls_log); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\n - name: client_mcp\n type: EXTENSION\n path: exts/client-mcp\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: project/configuration\n - name: client_mcp\n type: EXTENSION\n path: project/exts/client-mcp\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", work_path.display(), platform_path.display(), edt_cli_path.display(), @@ -712,8 +709,7 @@ fn build_text_groups_tool_extension_stages_under_single_build_node() { write_edt_script(&edt_cli_path, &edt_calls_log); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n client_mcp:\n extension:\n name: client_mcp\n source:\n path: '{}'\n format: EDT\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: project/configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n client_mcp:\n extension:\n name: client_mcp\n source:\n path: '{}'\n format: EDT\n", work_path.display(), platform_path.display(), edt_cli_path.display(), @@ -855,10 +851,9 @@ fn build_ibcmd_full_rebuild_invokes_import_and_apply() { #[test] fn build_ibcmd_passes_credentials_to_import_and_apply() { - let (dir, config_path, binary_path, work_path, base_path, calls_log) = setup_ibcmd_project(); + let (dir, config_path, binary_path, work_path, _base_path, calls_log) = setup_ibcmd_project(); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: 'File=/tmp/ib'\n user: Admin\n password: secret\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: 'File=/tmp/ib'\n user: Admin\n password: secret\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n", work_path.display(), binary_path.display(), ); diff --git a/tests/cli_config_init.rs b/tests/cli_config_init.rs index 1e20256..eaaec90 100644 --- a/tests/cli_config_init.rs +++ b/tests/cli_config_init.rs @@ -90,6 +90,7 @@ fn config_init_creates_yaml_with_detected_designer_sources() { ))); serde_yaml::from_str::(&config).expect("generated config remains YAML"); assert!(config.contains("format: DESIGNER")); + assert!(!config.contains("basePath:")); assert!(config.contains("workPath: 'build'")); assert!(config.contains("infobase:")); assert!(config.contains(" connection: 'File=build/ib'")); @@ -147,6 +148,7 @@ fn config_init_uses_json_envelope_and_output_override() { let config = fs::read_to_string(config_path).expect("config"); assert!(config.contains("infobase:")); assert!(config.contains(" connection: 'File=/tmp/test-ib'")); + assert!(!config.contains("basePath:")); } #[test] @@ -161,7 +163,10 @@ fn config_init_creates_local_overlay_next_to_output_override() { .expect("run command"); assert!(output.status.success()); - assert!(dir.path().join("config").join("v8project.yaml").exists()); + let config = + fs::read_to_string(dir.path().join("config").join("v8project.yaml")).expect("config"); + assert!(!config.contains("basePath:")); + assert!(config.contains("path: '..'")); let local_config = fs::read_to_string(dir.path().join("config").join("v8project.local.yaml")) .expect("local config"); assert!(local_config.starts_with(LOCAL_CONFIG_SCHEMA_MODEL_LINE)); diff --git a/tests/cli_convert.rs b/tests/cli_convert.rs index 7690e69..99c81ce 100644 --- a/tests/cli_convert.rs +++ b/tests/cli_convert.rs @@ -261,7 +261,7 @@ exit 0"#, fn write_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, edt_path: &Path, format: &str, @@ -269,8 +269,7 @@ fn write_config( platform_version: Option<&str>, ) { let mut config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: {format}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n", - base_path.display(), + "workPath: '{}'\nformat: {format}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n", work_path.display(), ); for source_set in source_sets { @@ -331,7 +330,7 @@ fn setup_project() -> ( let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let edt_cli_path = dir.path().join("edt").join("1cedtcli"); let calls_log = dir.path().join("edt-calls.log"); @@ -863,7 +862,7 @@ fn convert_output_root_mirrors_source_set_layout_and_stabilizes_edt_project_name let base_path = dir.path().join("designer"); let work_path = dir.path().join("work"); let output_root = dir.path().join("edt"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let edt_cli_path = dir.path().join("edt-cli").join("1cedtcli"); let calls_log = dir.path().join("edt-calls.log"); @@ -1099,7 +1098,7 @@ fn convert_output_root_rejects_base_path_child_before_workspace_lock() { assert!(payload["data"]["message"] .as_str() .expect("message") - .contains("must not be inside basePath")); + .contains("must not be inside project base path")); } #[test] diff --git a/tests/cli_dump.rs b/tests/cli_dump.rs index f1267e7..07dd738 100644 --- a/tests/cli_dump.rs +++ b/tests/cli_dump.rs @@ -114,14 +114,13 @@ fn write_config(path: &Path, base_path: &Path, work_path: &Path, platform_path: fn write_config_with_infobase( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, infobase_yaml: &str, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}source-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}source-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n", work_path.display(), infobase_yaml, platform_path.display(), @@ -164,14 +163,13 @@ fn setup_project() -> ( fn write_edt_dump_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, edt_path: &Path, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n interactive-mode: false\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n interactive-mode: false\n", work_path.display(), platform_path.display(), edt_path.display(), diff --git a/tests/cli_extensions.rs b/tests/cli_extensions.rs index aae717e..d6d6a64 100644 --- a/tests/cli_extensions.rs +++ b/tests/cli_extensions.rs @@ -84,7 +84,7 @@ fn setup_extensions_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let ibcmd_path = dir.path().join("ibcmd"); let calls_log = dir.path().join("ibcmd.calls.log"); @@ -104,8 +104,7 @@ fn setup_extensions_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) ); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File={}'\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\n - name: client_mcp\n type: EXTENSION\n path: exts/client-mcp\n - name: tests\n type: EXTENSION\n path: tests\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File={}'\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\n - name: client_mcp\n type: EXTENSION\n path: exts/client-mcp\n - name: tests\n type: EXTENSION\n path: tests\ntools:\n platform:\n path: '{}'\n", work_path.display(), dir.path().join("ib").display(), ibcmd_path.display(), @@ -133,7 +132,7 @@ fn extensions_command_updates_all_extension_properties() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("│")); assert!(stdout.contains("● client_mcp: disable_safety")); - assert!(stdout.contains("│ updating extension properties")); + assert!(stdout.contains("updating extension properties")); assert!(stdout.contains("│ безопасный режим")); assert!(stdout.contains("● tests: disable_safety")); assert!(stdout.contains("● Extension properties updated successfully")); diff --git a/tests/cli_init.rs b/tests/cli_init.rs index 86ce9b9..542f663 100644 --- a/tests/cli_init.rs +++ b/tests/cli_init.rs @@ -82,7 +82,7 @@ fn setup_designer_init_project_with_body( let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let v8_path = dir.path().join("1cv8"); let infobase_path = dir.path().join("ib"); @@ -94,8 +94,7 @@ fn setup_designer_init_project_with_body( ); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File={}'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File={}'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", work_path.display(), infobase_path.display(), v8_path.display(), @@ -120,7 +119,7 @@ fn setup_edt_init_project( let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let platform_path = dir .path() .join(if builder == "IBCMD" { "ibcmd" } else { "1cv8" }); @@ -158,8 +157,7 @@ fn setup_edt_init_project( ); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: {}\nbuilder: {}\ninfobase:\n connection: '{}'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\n - name: ext\n type: EXTENSION\n path: ext\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: {}\nbuilder: {}\ninfobase:\n connection: '{}'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\n - name: ext\n type: EXTENSION\n path: ext\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", work_path.display(), format, builder, @@ -185,7 +183,7 @@ fn setup_ibcmd_server_init_project( let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let ibcmd_path = dir.path().join("ibcmd"); let calls_log = dir.path().join("ibcmd.calls.log"); @@ -201,8 +199,7 @@ fn setup_ibcmd_server_init_project( ); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: 'Srvr=cluster:1541;Ref=demo'\n user: Admin\n password: secret\n dbms:\n kind: PostgreSQL\n server: localhost\n name: demo\n user: postgres\n password: pg-secret\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: 'Srvr=cluster:1541;Ref=demo'\n user: Admin\n password: secret\n dbms:\n kind: PostgreSQL\n server: localhost\n name: demo\n user: postgres\n password: pg-secret\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", work_path.display(), ibcmd_path.display(), ); diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index d4d94c2..7bd4647 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -22,14 +22,13 @@ fn write_logging_script(path: &Path, args_log: &Path) { fn write_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, platform_version: Option<&str>, ) { let mut config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\ntools:\n platform:\n path: '{}'\n", work_path.display(), platform_path.display(), ); @@ -126,8 +125,7 @@ fn setup_mcp_va_project_with_options( ) }; let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n client_mcp:\n port: 9874\n va:\n epf_path: '{}'\n platform:\n path: '{}'\n{}", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\ntools:\n client_mcp:\n port: 9874\n va:\n epf_path: '{}'\n platform:\n path: '{}'\n{}", work_path.display(), va_params.display(), features_dir.display(), @@ -428,7 +426,14 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { assert!(params_json["WorkspaceRoot"] .as_str() .expect("WorkspaceRoot") - .contains("/project")); + .contains( + config_path + .parent() + .expect("config dir") + .display() + .to_string() + .as_str() + )); assert_eq!(params_json["ОстановкаПриВозникновенииОшибки"], false); assert_eq!(params_json["СписокФичДляВыполнения"][0], "login"); assert_eq!(params_json["СписокТеговОтбор"][0], "smoke"); diff --git a/tests/cli_load.rs b/tests/cli_load.rs index 4a585a6..2fafb3e 100644 --- a/tests/cli_load.rs +++ b/tests/cli_load.rs @@ -56,14 +56,13 @@ fn write_edt_configuration_source(path: &Path, project_name: &str) { fn write_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, format: &str, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: {}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: {}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", work_path.display(), format, platform_path.display(), @@ -75,7 +74,7 @@ fn setup_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, PathBuf) { let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let binary_path = dir.path().join("1cv8"); let calls_log = dir.path().join("calls.log"); diff --git a/tests/cli_syntax.rs b/tests/cli_syntax.rs index efbc9a4..6fab50a 100644 --- a/tests/cli_syntax.rs +++ b/tests/cli_syntax.rs @@ -48,7 +48,7 @@ fn write_edt_configuration_source(path: &Path, project_name: &str) { fn write_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, format: &str, @@ -58,8 +58,7 @@ fn write_config( .map(|path| format!(" edt_cli:\n path: '{}'\n", path.display())) .unwrap_or_default(); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: {}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n platform:\n path: '{}'\n{}", - base_path.display(), + "workPath: '{}'\nformat: {}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n platform:\n path: '{}'\n{}", work_path.display(), format, platform_path.display(), @@ -73,7 +72,7 @@ fn setup_project(script_body: &str) -> (tempfile::TempDir, PathBuf) { let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); let install_dir = dir.path().join("platform"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); fs::create_dir_all(&base_path).expect("base"); fs::create_dir_all(&work_path).expect("work"); @@ -96,7 +95,7 @@ fn setup_edt_project(script_body: &str) -> (tempfile::TempDir, PathBuf) { let work_path = dir.path().join("work"); let install_dir = dir.path().join("platform"); let edt_cli = dir.path().join("edt").join("1cedtcli"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); fs::create_dir_all(&base_path).expect("base"); write_edt_configuration_source(&base_path, "main"); diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 51b08f4..823da66 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -141,7 +141,7 @@ fn write_va_test_script( fn write_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, install_dir: &Path, timeout_seconds: u64, @@ -159,8 +159,7 @@ fn write_config( ) }; let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: {}\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n{}", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: {}\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n{}", work_path.display(), timeout_seconds, install_dir.display(), @@ -204,7 +203,7 @@ fn setup_project_with_additional_launch_keys( let base_path = dir.path().join("project"); let work_path = dir.path().join(work_dir_name); let install_dir = dir.path().join("platform"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let build_calls = dir.path().join("build.calls.log"); let test_calls = dir.path().join("test.calls.log"); let captured_config = dir.path().join("captured-config.json"); @@ -259,7 +258,7 @@ fn setup_va_project_with_work_name( let base_path = dir.path().join("project"); let work_path = dir.path().join(work_dir_name); let install_dir = dir.path().join("platform"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let build_calls = dir.path().join("build.calls.log"); let test_calls = dir.path().join("test.calls.log"); let captured_params = dir.path().join("captured-va-params.json"); @@ -302,8 +301,7 @@ fn setup_va_project_with_work_name( ) }; let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: 5\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\n ignore_tags:\n - '@draft'\n scenario_filter:\n - Проверка логина\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n va:\n epf_path: '{}'\n platform:\n path: '{}'\n{}", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: 5\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\n ignore_tags:\n - '@draft'\n scenario_filter:\n - Проверка логина\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n va:\n epf_path: '{}'\n platform:\n path: '{}'\n{}", work_path.display(), va_params.display(), features_dir.display(), @@ -1000,7 +998,7 @@ fn test_module_edt_extension_build_uses_full_load_before_enterprise_launch() { let work_path = dir.path().join("work"); let install_dir = dir.path().join("platform"); let edt_cli_path = dir.path().join("edt").join("1cedtcli"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let build_calls = dir.path().join("build.calls.log"); let test_calls = dir.path().join("test.calls.log"); let edt_calls = dir.path().join("edt.calls.log"); @@ -1057,8 +1055,7 @@ fn test_module_edt_extension_build_uses_full_load_before_enterprise_launch() { write_edt_script(&edt_cli_path, &edt_calls); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\n - name: client_mcp\n type: EXTENSION\n path: exts/client-mcp\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\n - name: client_mcp\n type: EXTENSION\n path: exts/client-mcp\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", work_path.display(), install_dir.display(), edt_cli_path.display(), @@ -1114,7 +1111,7 @@ fn repeated_test_skips_unchanged_source_backed_tool_extension_build() { let work_path = dir.path().join("work"); let install_dir = dir.path().join("platform"); let edt_cli_path = dir.path().join("edt").join("1cedtcli"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let build_calls = dir.path().join("build.calls.log"); let test_calls = dir.path().join("test.calls.log"); let edt_calls = dir.path().join("edt.calls.log"); @@ -1148,8 +1145,7 @@ fn repeated_test_skips_unchanged_source_backed_tool_extension_build() { write_edt_script(&edt_cli_path, &edt_calls); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n client_mcp:\n extension:\n name: client_mcp\n source:\n path: '{}'\n format: EDT\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n client_mcp:\n extension:\n name: client_mcp\n source:\n path: '{}'\n format: EDT\n", work_path.display(), install_dir.display(), edt_cli_path.display(), diff --git a/tests/mcp_http.rs b/tests/mcp_http.rs index fbf68c1..b670bbd 100644 --- a/tests/mcp_http.rs +++ b/tests/mcp_http.rs @@ -97,7 +97,7 @@ fn write_interactive_edt_script( fn write_http_designer_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, bind_address: &str, @@ -106,8 +106,7 @@ fn write_http_designer_config( idle_ttl_secs: u64, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: {}\n max_sessions: {}\n idle_ttl_secs: {}\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: {}\n max_sessions: {}\n idle_ttl_secs: {}\ntools:\n platform:\n path: '{}'\n", work_path.display(), bind_address, stateful_sessions, @@ -120,7 +119,7 @@ fn write_http_designer_config( fn write_http_ibcmd_config_with_infobase( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, ibcmd_path: &Path, bind_address: &str, @@ -129,8 +128,7 @@ fn write_http_ibcmd_config_with_infobase( infobase_yaml: &str, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}source-set:\n - name: main\n type: CONFIGURATION\n path: main\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: true\n max_sessions: {}\n idle_ttl_secs: {}\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}source-set:\n - name: main\n type: CONFIGURATION\n path: project/main\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: true\n max_sessions: {}\n idle_ttl_secs: {}\ntools:\n platform:\n path: '{}'\n", work_path.display(), infobase_yaml, bind_address, @@ -195,7 +193,7 @@ fn write_edt_configuration_source(path: &Path, project_name: &str) { fn write_http_edt_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, edt_path: &Path, bind_address: &str, @@ -205,8 +203,7 @@ fn write_http_edt_config( command_timeout_ms: u64, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main-edt\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: true\n max_sessions: {}\n idle_ttl_secs: {}\n execution:\n max_concurrent_calls: {}\ntools:\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main-edt\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: true\n max_sessions: {}\n idle_ttl_secs: {}\n execution:\n max_concurrent_calls: {}\ntools:\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", work_path.display(), bind_address, max_sessions, diff --git a/tests/mcp_stdio.rs b/tests/mcp_stdio.rs index f9fc344..4ddb35a 100644 --- a/tests/mcp_stdio.rs +++ b/tests/mcp_stdio.rs @@ -64,10 +64,9 @@ fn run_cli_json_with_status(config_path: &Path, args: &[&str]) -> (bool, Value) ) } -fn write_config(path: &Path, base_path: &Path, work_path: &Path, platform_path: &Path) { +fn write_config(path: &Path, _base_path: &Path, work_path: &Path, platform_path: &Path) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nexecution_timeout: 300000\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nexecution_timeout: 300000\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\ntools:\n platform:\n path: '{}'\n", work_path.display(), platform_path.display(), ); @@ -76,15 +75,14 @@ fn write_config(path: &Path, base_path: &Path, work_path: &Path, platform_path: fn write_edt_config_with_options( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, edt_path: &Path, command_timeout_ms: u64, max_concurrent_calls: usize, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nexecution_timeout: {}\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main-edt\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", - base_path.display(), + "workPath: '{}'\nexecution_timeout: {}\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main-edt\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", work_path.display(), command_timeout_ms, max_concurrent_calls, @@ -96,15 +94,14 @@ fn write_edt_config_with_options( fn write_designer_config_with_options( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, command_timeout_ms: u64, max_concurrent_calls: usize, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nexecution_timeout: 300000\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n platform:\n path: '{}'\n edt_cli:\n command_timeout_ms: {}\n", - base_path.display(), + "workPath: '{}'\nexecution_timeout: 300000\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n platform:\n path: '{}'\n edt_cli:\n command_timeout_ms: {}\n", work_path.display(), max_concurrent_calls, platform_path.display(), @@ -115,7 +112,7 @@ fn write_designer_config_with_options( fn write_edt_config_with_platform( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, edt_path: &Path, @@ -123,8 +120,7 @@ fn write_edt_config_with_platform( max_concurrent_calls: usize, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nexecution_timeout: {}\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main-edt\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", - base_path.display(), + "workPath: '{}'\nexecution_timeout: {}\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main-edt\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", work_path.display(), command_timeout_ms, max_concurrent_calls, @@ -287,13 +283,12 @@ fn setup_hybrid_edt_project_with_options( fn write_designer_suite_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nmcp:\n execution:\n max_concurrent_calls: 1\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nmcp:\n execution:\n max_concurrent_calls: 1\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n", work_path.display(), platform_path.display(), ); @@ -345,14 +340,13 @@ fn setup_designer_suite_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathB fn write_ibcmd_config_with_infobase( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, ibcmd_path: &Path, infobase_yaml: &str, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}mcp:\n execution:\n max_concurrent_calls: 1\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}mcp:\n execution:\n max_concurrent_calls: 1\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n", work_path.display(), infobase_yaml, ibcmd_path.display(), @@ -616,8 +610,7 @@ fn mcp_unsupported_main_config_shape_reports_error_on_stderr() { fs::write( &config_path, format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n platform:\n typo: value\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\ntools:\n platform:\n typo: value\n", work_path.display() ), ) From 3e4d57e03fbe956f4efe068c4e159b806ee93879 Mon Sep 17 00:00:00 2001 From: alkoleft Date: Mon, 11 May 2026 22:59:15 +0300 Subject: [PATCH 03/16] feat(config): use master schema URLs - point generated schema ids and modelines to master - update configuration docs and config init assertion --- docs/CONFIGURATION.md | 9 ++++----- docs/schemas/v8project.local.schema.json | 2 +- docs/schemas/v8project.schema.json | 2 +- src/config/schema.rs | 9 +++------ tests/cli_config_init.rs | 7 +++---- 5 files changed, 12 insertions(+), 17 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 81aca77..0f9f698 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -71,7 +71,7 @@ schema artifacts для редактирования `v8project.yaml` и `v8proj `v8-runner config init` пишет в начало `v8project.yaml` modeline: ```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.4.2/docs/schemas/v8project.schema.json +# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.schema.json ``` В VS Code установите расширение `redhat.vscode-yaml`. Оно использует эту строку @@ -89,14 +89,13 @@ settings: ```json { "yaml.schemas": { - "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.4.2/docs/schemas/v8project.local.schema.json": "v8project.local.yaml" + "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json": "v8project.local.yaml" } } ``` -Schema version равна версии приложения из `Cargo.toml` и release tag: `v0.4.2` публикует schemas -для `v8-runner 0.4.2`. Для воспроизводимого редактирования используйте raw URL с `refs/tags/vX.Y.Z`; -веточные raw URLs допустимы только для разработки следующей версии. +Schema URL всегда указывает на `master`, чтобы IDE подхватывала актуальный опубликованный schema +artifact без привязки к release tag. ## Именование ключей diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index 457579a..f2c1ec2 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -552,7 +552,7 @@ "type": "object" } }, - "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.4.2/docs/schemas/v8project.local.schema.json", + "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index 4882b44..a96fc4f 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -593,7 +593,7 @@ "type": "object" } }, - "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.4.2/docs/schemas/v8project.schema.json", + "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { diff --git a/src/config/schema.rs b/src/config/schema.rs index b27f74f..4796d4b 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -11,8 +11,8 @@ use serde_json::{json, Value}; pub const MAIN_CONFIG_SCHEMA_PATH: &str = "docs/schemas/v8project.schema.json"; pub const LOCAL_CONFIG_SCHEMA_PATH: &str = "docs/schemas/v8project.local.schema.json"; -const REPOSITORY_RAW_TAG_BASE: &str = - "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags"; +const REPOSITORY_RAW_SCHEMA_BASE: &str = + "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas"; pub fn main_config_schema_url() -> String { schema_url("v8project.schema.json") @@ -58,10 +58,7 @@ pub fn schema_json_pretty(schema: &Value) -> String { } fn schema_url(file_name: &str) -> String { - format!( - "{REPOSITORY_RAW_TAG_BASE}/v{}/docs/schemas/{file_name}", - env!("CARGO_PKG_VERSION") - ) + format!("{REPOSITORY_RAW_SCHEMA_BASE}/{file_name}") } fn set_schema_id(schema: &mut Value, id: &str) { diff --git a/tests/cli_config_init.rs b/tests/cli_config_init.rs index eaaec90..eda20c1 100644 --- a/tests/cli_config_init.rs +++ b/tests/cli_config_init.rs @@ -84,10 +84,9 @@ fn config_init_creates_yaml_with_detected_designer_sources() { assert!(output.status.success()); let config = fs::read_to_string(dir.path().join("v8project.yaml")).expect("config"); - assert!(config.starts_with(&format!( - "# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v{}/docs/schemas/v8project.schema.json\n", - env!("CARGO_PKG_VERSION") - ))); + assert!(config.starts_with( + "# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.schema.json\n" + )); serde_yaml::from_str::(&config).expect("generated config remains YAML"); assert!(config.contains("format: DESIGNER")); assert!(!config.contains("basePath:")); From 608ad24763f82e739d955b88b399ba1a420dafeb Mon Sep 17 00:00:00 2001 From: alkoleft Date: Mon, 11 May 2026 23:12:05 +0300 Subject: [PATCH 04/16] docs(spec): update latest config spec - record public basePath removal - document master schema URL contract --- SKILL/SKILL.md | 2 +- docs/CONFIGURATION.md | 4 ++-- spec/IMPLEMENTATION_TODO.md | 4 +++- spec/archive/completed-tasks-t23.md | 7 +++++++ spec/archive/completed-tasks-t26.md | 32 +++++++++++++++++++++++++++++ 5 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 spec/archive/completed-tasks-t26.md diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index ec6c6c4..fbd6429 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -22,7 +22,7 @@ Use the available `v8-runner` binary directly. If it is not on `PATH`, ask for t `v8project.yaml` is the default project config name. A sibling `v8project.local.yaml` is loaded automatically for machine-local paths, credentials, tools, tests, and MCP settings. Do not pass `--config v8project.yaml` unless the user explicitly wants a non-default command shape or the active config path differs from the default; never pass `v8project.local.yaml` as `--config`. -Generated `v8project.yaml` files include a `yaml-language-server` modeline that points to the versioned JSON Schema for the current `v8-runner` release. `config init` also creates sibling `v8project.local.yaml` with the local overlay schema modeline and adds it to `.gitignore` when needed. +Generated `v8project.yaml` files include a `yaml-language-server` modeline that points to the published `master` JSON Schema artifact. `config init` also creates sibling `v8project.local.yaml` with the local overlay schema modeline and adds it to `.gitignore` when needed. Use JSON output only when another tool, script, or final answer needs structured results: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0f9f698..b21f6a6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -31,8 +31,8 @@ v8-runner config init Что делает `config init`: - создаёт `v8project.yaml` в текущем каталоге или по `--output `; -- добавляет modeline `yaml-language-server` со ссылкой на versioned schema для текущей версии - `v8-runner`; +- добавляет modeline `yaml-language-server` со ссылкой на опубликованный schema artifact в + ветке `master`; - создаёт рядом пустой `v8project.local.yaml` с modeline на `https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json`; - добавляет `v8project.local.yaml` в `.gitignore`, если подходящий pattern еще не указан; diff --git a/spec/IMPLEMENTATION_TODO.md b/spec/IMPLEMENTATION_TODO.md index bc81418..68c884d 100644 --- a/spec/IMPLEMENTATION_TODO.md +++ b/spec/IMPLEMENTATION_TODO.md @@ -4,7 +4,7 @@ This file tracks open implementation work only. ## Current Status -- Open tasks as of `2026-05-03`: 0. +- Open tasks as of `2026-05-11`: 0. ## Open Tasks @@ -35,3 +35,5 @@ No open implementation tasks. closed source-backed tool extension change-detection task. - [spec/archive/completed-tasks-t25.md](archive/completed-tasks-t25.md): closed JSON Schema descriptions and config alias removal task. +- [spec/archive/completed-tasks-t26.md](archive/completed-tasks-t26.md): + closed post-T25 public `basePath` removal and schema URL follow-up. diff --git a/spec/archive/completed-tasks-t23.md b/spec/archive/completed-tasks-t23.md index 0ae9e47..c9c1382 100644 --- a/spec/archive/completed-tasks-t23.md +++ b/spec/archive/completed-tasks-t23.md @@ -14,6 +14,13 @@ Implemented scope: schema URL for the current application version. - Documented VS Code setup, local overlay schema usage and schema versioning policy. +Current status after `2026-05-11` follow-up: + +- `basePath` is no longer a public YAML key; the project base path is derived from the primary + config directory. +- Generated schema `$id` values and `yaml-language-server` modelines now point to the published + `master` schema artifacts. + Verification: - `cargo test --locked config::schema::tests` diff --git a/spec/archive/completed-tasks-t26.md b/spec/archive/completed-tasks-t26.md new file mode 100644 index 0000000..d35557e --- /dev/null +++ b/spec/archive/completed-tasks-t26.md @@ -0,0 +1,32 @@ +# Completed Task T26 + +## T26: Sync public basePath removal and master schema URLs + +Status: completed on `2026-05-11`. + +Implemented scope: + +- `config init --output ` resolves generated `source-set[].path` relative to the selected + primary config directory, not necessarily the current working directory. +- `basePath` was removed from the public YAML contract; the internal project base path is derived + from the primary config directory. +- Generated JSON Schema `$id` values and `yaml-language-server` modelines point to the published + `master` schema artifacts, not release-tag schema URLs. + +Specification surfaces synchronized: + +- `spec/decisions/0021-lokalnyy-overlay-config.md` +- `spec/architecture/invariants.md` +- `spec/acceptance/real-environment-validation.md` +- `spec/archive/completed-tasks-t23.md` +- `docs/CONFIGURATION.md` +- `docs/CAPABILITIES.md` +- `docs/DEEP_DIVE.md` +- `README.md` +- `SKILL/SKILL.md` and focused skill references + +Verification: + +- `cargo test --locked generated_schema` +- `cargo test --locked config_init` +- `cargo test --locked cli_config_init` From 64692565d071fc7bcb361232c986999a20beda77 Mon Sep 17 00:00:00 2001 From: alkoleft Date: Tue, 12 May 2026 00:04:36 +0300 Subject: [PATCH 05/16] feat(tools): add tools download command - download YAxUnit, Vanessa Automation Single, and client MCP devkit from latest GitHub releases - support extension sources/artifacts modes and update local tool configuration - document the workflow and cover redirects, safety checks, and config bootstrap tests --- Cargo.lock | 97 ++ Cargo.toml | 1 + README.md | 13 +- SKILL/SKILL.md | 6 + docs/CAPABILITIES.md | 20 + docs/CONFIGURATION.md | 10 + .../arc42/04-solution-strategy.md | 1 + .../arc42/05-building-block-view.md | 7 +- spec/architecture/arc42/06-runtime-view.md | 22 +- src/app.rs | 34 +- src/cli/args.rs | 26 + src/cli/execute.rs | 140 ++- src/config/loader.rs | 43 +- src/config/validate.rs | 17 + src/domain/mod.rs | 2 + src/domain/tools_download.rs | 28 + src/platform/download.rs | 110 +++ src/platform/mod.rs | 1 + src/use_cases/context.rs | 2 + src/use_cases/mod.rs | 2 + src/use_cases/request.rs | 12 + src/use_cases/tools_download.rs | 829 ++++++++++++++++++ tests/cli_help.rs | 15 + tests/cli_tools_download.rs | 714 +++++++++++++++ tests/support/mod.rs | 8 + 25 files changed, 2145 insertions(+), 15 deletions(-) create mode 100644 src/domain/tools_download.rs create mode 100644 src/platform/download.rs create mode 100644 src/use_cases/tools_download.rs create mode 100644 tests/cli_tools_download.rs diff --git a/Cargo.lock b/Cargo.lock index f6adb81..b0fa8af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -46,6 +52,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "assert_cmd" version = "2.2.0" @@ -331,6 +346,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -375,6 +405,17 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "difflib" version = "0.4.0" @@ -462,6 +503,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -1073,6 +1124,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1871,6 +1932,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "similar" version = "2.7.0" @@ -2317,6 +2384,7 @@ dependencies = [ "tracing-subscriber", "uuid", "walkdir", + "zip", ] [[package]] @@ -2925,8 +2993,37 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 43a7c8d..92c34cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ chrono = { version = "0.4", features = ["serde"] } libc = "0.2" regex = "1" uuid = { version = "1", features = ["v4"] } +zip = { version = "2", default-features = false, features = ["deflate"] } rmcp = { version = "1.2.0", default-features = false, features = ["client", "macros", "server", "transport-child-process", "transport-io", "transport-streamable-http-server"] } tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-util = "0.7" diff --git a/README.md b/README.md index b9965f2..89b4a09 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,17 @@ overlay в `.gitignore`, если он еще не указан. Machine-local пути, credentials и настройки инструментов можно вынести в `v8project.local.yaml` рядом с основным конфигом. Этот файл применяется автоматически и должен оставаться вне Git. +### Загрузите тестовые и MCP-инструменты: + +```bash +v8-runner tools download +``` + +По умолчанию команда берёт latest releases YAxUnit, Vanessa Automation single и +onec-client-mcp-devkit, добавляет YAxUnit sources как `source-set` `tests`, кладёт tool artifacts в +`build/tools` и обновляет `v8project.local.yaml`. Для `.cfe`-режима расширений используйте +`v8-runner tools download --extensions artifacts` в проектах с `builder=DESIGNER`. + ### Подготовьте рабочую информационную базу: ```bash @@ -107,7 +118,7 @@ v8-runner mcp serve stdio | Зона | Команды | Что делает | | --- | --- | --- | -| Project setup (настройка проекта) | `config init`, `init`, `extensions`, `build` | Создает config, готовит ИБ, обновляет расширения и загружает исходники | +| Project setup (настройка проекта) | `config init`, `tools download`, `init`, `extensions`, `build` | Создает config, скачивает инструменты, готовит ИБ, обновляет расширения и загружает исходники | | Verification (проверка) | `syntax`, `test` | Запускает syntax checks, YAxUnit и Vanessa Automation | | File materialization (материализация файлов) | `dump`, `convert`, `load`, `make`, `artifacts` | Выгружает, конвертирует, загружает и публикует `.cf`, `.cfe`, `.epf`, `.erf` | | Direct launch (прямой запуск) | `launch `, `launch mcp [va]` | Запускает 1C clients (клиенты 1С), Designer и MCP/Vanessa сценарии | diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index fbd6429..5017ec3 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -56,6 +56,8 @@ v8-runner config init v8-runner config init --connection "File=build/ib" v8-runner config init --format edt v8-runner config init --builder IBCMD +v8-runner tools download +v8-runner tools download --extensions artifacts v8-runner init ``` @@ -66,6 +68,10 @@ v8-runner init - Branch switch, rebase, large object moves, stale source-backed tool extension state, or suspicious incremental state: run `v8-runner build --full-rebuild`. - Syntax check: inspect `format` and `builder`, then choose `syntax designer-modules`, `syntax designer-config`, or `syntax edt`. - Behavior validation: run the relevant `v8-runner test ...` command; tests build first. +- Missing local YAxUnit, Vanessa Automation, or onec-client-mcp-devkit setup: run + `v8-runner tools download` for source-backed extensions, or + `v8-runner tools download --extensions artifacts` for `.cfe`-backed extensions when + `builder=DESIGNER`. - Vanessa Automation debugging or scenario authoring: use `v8-runner launch mcp va ...` to start the client MCP server with VA loaded. - Extension properties need synchronization: use `v8-runner extensions` or `extensions --name `. - Infobase changes need to become Git-visible files: check `git status`, then run the relevant `v8-runner dump ...` command. diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index ad61ec1..781563c 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -21,6 +21,7 @@ CLI help, доверяйте текущему коду и затем синхр | Сценарий | Поддерживаемые комбинации | Примечания | | --- | --- | --- | | `config init` | Работает без существующего конфига | Создаёт `v8project.yaml`, sibling `v8project.local.yaml`, `.gitignore` entry, autodetect-ит supported `source-set` и aggregate external roots | +| `tools download` | CLI-only загрузка latest releases | Загружает YAxUnit, Vanessa Automation single и onec-client-mcp-devkit; обновляет local overlay и при source-mode добавляет YAxUnit как `source-set` `tests` | | `init` | `format=DESIGNER` + `builder=DESIGNER` | Создаёт файловую ИБ через Designer; server connection остаётся manual prerequisite | | `init` | `format=DESIGNER` + `builder=IBCMD` | Выполняет `ensure` файловой или серверной ИБ через `ibcmd infobase create` | | `init` | `format=EDT` + `builder=DESIGNER|IBCMD` | Готовит ИБ по правилам builder и импортирует EDT workspace | @@ -98,6 +99,25 @@ v8-runner init - Если настроен `tools.client_mcp.extension.source.format=EDT`, импортирует этот tool extension project в EDT workspace, не добавляя его в project `source-set`. +### `tools download` + +```bash +v8-runner tools download [--extensions ] [--force] +``` + +- CLI-only; не публикуется как MCP tool. +- Берёт latest release из GitHub для `bia-technologies/yaxunit`, + `Pr-Mex/vanessa-automation-single` и `1c-neurofish/onec-client-mcp-devkit`. +- `--extensions sources` используется по умолчанию: YAxUnit распаковывается в `tests` и + добавляется в primary `v8project.yaml` как `source-set` с именем `tests`, а + onec-client-mcp-devkit распаковывается в `build/tools/onec-client-mcp-devkit/exts/client-mcp`. +- `--extensions artifacts` требует `builder=DESIGNER`, скачивает `.cfe` расширений в + `build/tools` и не меняет список `source-set`. +- Vanessa Automation single всегда скачивается как `build/tools/vanessa-automation-single.epf`. +- `v8project.local.yaml` обновляется machine-local путями для `tools.va.epf_path` и + `tools.client_mcp.extension`; повторный запуск переиспользует уже скачанные файлы, а + `--force` перезаписывает только managed targets, созданные `tools download`. + ### `extensions` ```bash diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b21f6a6..d5865f1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -465,12 +465,22 @@ source-set build, а `launch mcp` и `launch mcp va` расширение не неизменёнными исходниками пропускает export/load, а `build --full-rebuild` принудительно обновляет расширение. +`v8-runner tools download` может заполнить этот блок в `v8project.local.yaml`: при +`--extensions sources` он указывает `source.path` на +`build/tools/onec-client-mcp-devkit/exts/client-mcp` и `source.format: EDT`, при +`--extensions artifacts` указывает `artifact.path` на скачанный `client_mcp.cfe`. +Artifact-режим доступен только для `builder=DESIGNER`; для `builder=IBCMD` используйте +source-режим. + ### `tools.va` Поддержанные поля: - `epf_path`, путь к внешней обработке Vanessa Automation. +`v8-runner tools download` заполняет `tools.va.epf_path` в `v8project.local.yaml` путём +`build/tools/vanessa-automation-single.epf`. + ## `tools.platform` ### `tools.platform.path` diff --git a/spec/architecture/arc42/04-solution-strategy.md b/spec/architecture/arc42/04-solution-strategy.md index f35d8fc..1028131 100644 --- a/spec/architecture/arc42/04-solution-strategy.md +++ b/spec/architecture/arc42/04-solution-strategy.md @@ -13,6 +13,7 @@ - Публичные команды над одним canonical `workPath` сериализуются через workspace lock; nested flows используют явные unlocked entrypoints только под внешним lock. - Timeout/cancellation реализуются поверх общего execution core по host-specific policy: MCP может отсоединять caller от running EDT работы и удерживать capacity до terminal state, а CLI blocking flows ждут terminal cleanup или принудительно закрывают свой shared EDT manager перед возвратом. - Full replacement `dump` и `artifacts` публикуются через staging/backup, чтобы platform failure до publish сохранял старый target. +- `tools download` остаётся CLI-only bootstrap-сценарием: он материализует внешние release assets в рабочие каталоги проекта и обновляет local overlay, но не расширяет MCP surface и не выполняет platform load в ИБ. - Runner-like сценарии используют общий execution grammar: pipeline vocabulary, step entries и `ExecutionOutcome` как canonical domain outcome. - Общий интерактивный EDT actor вынесен в `platform::edt_session` и переиспользуется и MCP `check_syntax_edt`, и CLI interactive EDT use cases; различается только host policy (MCP может prewarm shared host, CLI остаётся lazy и short-lived). - Архитектура оптимизирована под agent-friendly contracts: use case возвращают transport-neutral DTO и структурированные failure payload, а логика представления остаётся на границе адаптера. diff --git a/spec/architecture/arc42/05-building-block-view.md b/spec/architecture/arc42/05-building-block-view.md index cb00d56..6c32d76 100644 --- a/spec/architecture/arc42/05-building-block-view.md +++ b/spec/architecture/arc42/05-building-block-view.md @@ -37,22 +37,23 @@ flowchart TB - Преобразует аргументы `clap` в транспортно-нейтральные запросы. - Отвечает за разбор аргументов и CLI-специфичный рендеринг результатов. -- Публикует команды `config init`, `init`, `extensions`, `build`, `load`, `test`, `dump`, `convert`, `make`/`artifacts`, `syntax`, `launch` и `mcp`. +- Публикует команды `config init`, `tools download`, `init`, `extensions`, `build`, `load`, `test`, `dump`, `convert`, `make`/`artifacts`, `syntax`, `launch` и `mcp`. #### `use_cases` -- Центральная оркестрация для `config init`, `init`, `extensions`, `build`, `load`, `test`, `dump`, `convert`, `artifacts`, `syntax` и `launch`. +- Центральная оркестрация для `config init`, `tools download`, `init`, `extensions`, `build`, `load`, `test`, `dump`, `convert`, `artifacts`, `syntax` и `launch`. - Определяет transport-neutral request/result contracts, которые должны оставаться стабильной внутренней опорой для адаптеров и AI-агентов, работающих через эти адаптеры. - Предоставляет workspace lock helper и internal unlocked entrypoints для nested flows вроде `test -> build`; public lock boundary остаётся в CLI/MCP adapters. - Для runner-like сценариев собирает typed pipeline-like flow и заполняет `ExecutionOutcome` вместо нового ad hoc result shape. - Для `convert` выводит direction из `format`, резолвит `source-set` из `v8project.yaml` и публикует generated output либо под default `workPath/convert/out`, либо под explicit `--output` root с mirror-layout. +- Для `tools download` получает latest release metadata, скачивает выбранные sources/artifacts, обновляет `v8project.local.yaml` и при source-mode добавляет YAxUnit как project `source-set` `tests`. #### `mcp` - Преобразует MCP tool-запросы в запросы use case. - Публикует восемь текущих MCP-инструментов. - Обрабатывает stdio- и HTTP-транспорты, трекинг сессий, execution admission, HTTP session capacity и общий EDT actor-path. -- Намеренно не публикует весь CLI: `config init`, `init`, `extensions`, `load`, `convert` и `make`/`artifacts` остаются CLI-only сценариями. +- Намеренно не публикует весь CLI: `config init`, `tools download`, `init`, `extensions`, `load`, `convert` и `make`/`artifacts` остаются CLI-only сценариями. #### `platform` diff --git a/spec/architecture/arc42/06-runtime-view.md b/spec/architecture/arc42/06-runtime-view.md index 43819ec..fc62365 100644 --- a/spec/architecture/arc42/06-runtime-view.md +++ b/spec/architecture/arc42/06-runtime-view.md @@ -71,7 +71,23 @@ sequenceDiagram - Используется как более узкий operational path по сравнению с `build`, когда нужно синхронизировать свойства расширений без полной загрузки исходников. - Так как операция мутирует ИБ, будущая общая execution policy должна помечать соответствующий platform step как critical DB phase. -### 6.4 Сценарий MCP EDT Syntax +### 6.4 Сценарий `tools download` + +- CLI adapter получает workspace lock, потому что команда меняет primary config, local overlay и + локальные tool directories. +- Use case последовательно читает latest release metadata для YAxUnit, Vanessa Automation single и + onec-client-mcp-devkit. +- Для `--extensions sources` распаковываются source subtrees: YAxUnit в `tests`, client MCP в + `build/tools/onec-client-mcp-devkit/exts/client-mcp`; primary config получает `source-set` + `tests`, если его ещё нет. +- Для `--extensions artifacts` команда требует `builder=DESIGNER`; `.cfe` расширений + скачиваются в `build/tools`, а primary `source-set` не меняется. +- Vanessa Automation single всегда материализуется как `build/tools/vanessa-automation-single.epf`. +- `v8project.local.yaml` обновляется machine-local настройками `tools.va.epf_path` и + `tools.client_mcp.extension`; загрузка не устанавливает расширения в ИБ, не подменяет `build` + и при `--force` заменяет только managed targets, созданные этой командой. + +### 6.5 Сценарий MCP EDT Syntax - MCP-запрос приходит через stdio или HTTP. - Глобальный admission control ограничивает параллельные tool-вызовы. @@ -79,7 +95,7 @@ sequenceDiagram - Ожидание в очереди, baseline reset/probe и выполнение команды используют один и тот же ограниченный бюджет таймаута. - Host policy различается: MCP может отпустить caller после running cancel/timeout и дождаться terminal state асинхронно внутри shared actor, а CLI blocking adapter ждёт terminal cleanup или завершает собственный short-lived manager принудительно перед возвратом. -### 6.5 Full Replacement `dump` / `artifacts` Publication +### 6.6 Full Replacement `dump` / `artifacts` Publication ```mermaid sequenceDiagram @@ -117,7 +133,7 @@ sequenceDiagram - `dump incremental` и `dump partial` не получают full replacement guarantee и остаются non-atomic update modes. - Publication phase после переноса старого target в backup является filesystem critical phase. -### 6.6 Command Boundary, Admission и Cancellation +### 6.7 Command Boundary, Admission и Cancellation - CLI и MCP используют разные public surfaces, но сходятся в transport-neutral use case boundary. - MCP tool call сначала проходит execution admission; HTTP session capacity проверяется отдельно на transport lifecycle. diff --git a/src/app.rs b/src/app.rs index 72bf012..f193d09 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,12 +2,14 @@ use clap::Parser; use tracing::{debug, error}; use crate::cli::args::{ - Cli, Command, ConfigCommand, ConfigInitArgs, McpCommand, McpServeTransport, + Cli, Command, ConfigCommand, ConfigInitArgs, McpCommand, McpServeTransport, ToolsCommand, }; use crate::cli::execute; use crate::cli::output::print_command_error; use crate::command_envelope::Envelope; -use crate::config::loader::load_config; +use crate::config::loader::{ + load_config, load_config_for_tools_download, resolve_primary_config_path, +}; use crate::output::presenter::Presenter; use crate::output::text::{TimelineItem, TimelineStatus}; use crate::support::error::AppError; @@ -35,7 +37,7 @@ pub fn run() -> i32 { return run_config_command(args, &presenter); } - let config = match load_config(cli.config.as_deref(), cli.workdir.as_deref()) { + let config = match load_cli_config(&cli) { Ok(c) => c, Err(e) => { let message = e.to_string(); @@ -44,6 +46,15 @@ pub fn run() -> i32 { return error.exit_code(); } }; + let primary_config_path = match resolve_primary_config_path(cli.config.as_deref()) { + Ok(path) => path, + Err(e) => { + let message = e.to_string(); + let error = UseCaseError::from(AppError::from(e)); + print_command_error(&presenter, command_name(&cli.command), &error, &message); + return error.exit_code(); + } + }; let level = cli.log_level.as_deref().unwrap_or("info"); let action_log_path = match crate::support::logging::init_action_logging( @@ -74,6 +85,7 @@ pub fn run() -> i32 { let result = match &cli.command { Command::Init | Command::Config(_) + | Command::Tools(_) | Command::Extensions(_) | Command::Build(_) | Command::Load(_) @@ -85,6 +97,7 @@ pub fn run() -> i32 { | Command::Launch(_) => execute::execute_command( &config, &cli.command, + Some(primary_config_path), &presenter, cli.clean_before_execution, ), @@ -110,6 +123,21 @@ pub fn run() -> i32 { } } +fn load_cli_config( + cli: &Cli, +) -> Result { + if matches!( + &cli.command, + Command::Tools(crate::cli::args::ToolsArgs { + command: ToolsCommand::Download(_) + }) + ) { + load_config_for_tools_download(cli.config.as_deref(), cli.workdir.as_deref()) + } else { + load_config(cli.config.as_deref(), cli.workdir.as_deref()) + } +} + fn command_name(command: &Command) -> &'static str { match command { Command::Config(_) => "config", diff --git a/src/cli/args.rs b/src/cli/args.rs index a4f427c..c057d91 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -45,6 +45,8 @@ pub struct Cli { pub enum Command { /// Generate project configuration and autodetect source-sets Config(ConfigArgs), + /// Download YaXUnit, Vanessa Automation, and client MCP tool assets + Tools(ToolsArgs), /// Initialize the infobase and EDT workspace Init, /// Update configured extension properties inside the infobase @@ -70,6 +72,30 @@ pub enum Command { Mcp(McpArgs), } +#[derive(Args, Debug)] +pub struct ToolsArgs { + #[command(subcommand)] + pub command: ToolsCommand, +} + +#[derive(Subcommand, Debug)] +pub enum ToolsCommand { + /// Download supported test and MCP helper tools from latest GitHub releases + Download(ToolsDownloadArgs), +} + +#[derive(Args, Debug)] +#[command(next_help_heading = "Command options")] +pub struct ToolsDownloadArgs { + /// Installation mode for extension tools + #[arg(long, default_value = "sources", value_parser = ["sources", "artifacts"])] + pub extensions: String, + + /// Re-download managed targets created by tools download + #[arg(long)] + pub force: bool, +} + #[derive(Args, Debug)] pub struct ConfigArgs { #[command(subcommand)] diff --git a/src/cli/execute.rs b/src/cli/execute.rs index afde3a0..e2bbaa9 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -9,6 +9,7 @@ use crate::cli::args::{ ArtifactsArgs, BuildArgs, Command, ConvertArgs, DesignerConfigSyntaxArgs, DesignerModulesSyntaxArgs, DumpArgs, ExtensionsArgs, LaunchArgs, LaunchOptionsArgs, LoadArgs, SyntaxArgs, SyntaxTarget, TestArgs, TestRunner, TestScope, TestVaArgs, TestYaxunitArgs, + ToolsArgs, ToolsCommand, ToolsDownloadArgs, }; use crate::cli::output::{ failure_envelope, pre_dispatch_error_envelope, print_command_use_case_error, with_cli_error, @@ -39,6 +40,7 @@ use crate::domain::runner::{ }; use crate::domain::syntax::{SyntaxCheckResult, SyntaxCheckStatus}; use crate::domain::test::{RetainedPaths, TestReport, TestRunResult, TestStatus, TestTarget}; +use crate::domain::tools_download::{ToolExtensionInstallMode, ToolsDownloadResult}; use crate::output::presenter::Presenter; use crate::output::text::{TimelineItem, TimelineStatus}; use crate::support::adapter_input::{ @@ -64,10 +66,11 @@ use crate::use_cases::request::{ DesignerClientScope, DesignerClientScopes, DesignerConfigCheck, DesignerConfigChecks, DesignerConfigSyntaxRequest, DesignerModulesSyntaxRequest, DumpRequest, InitRequest, LaunchRequest, LoadRequest, SyntaxExtensionScope, SyntaxRequest, SyntaxTargetRequest, - TestRequest, TestScopeRequest, + TestRequest, TestScopeRequest, ToolsDownloadRequest, }; use crate::use_cases::result::{UseCaseError, UseCaseErrorKind}; use crate::use_cases::run_tests; +use crate::use_cases::tools_download; use crate::use_cases::transport::dispatch_with_workspace_lock; /// Executes a parsed CLI command by mapping it into transport-neutral requests and @@ -75,6 +78,7 @@ use crate::use_cases::transport::dispatch_with_workspace_lock; pub fn execute_command( config: &AppConfig, command: &Command, + primary_config_path: Option, presenter: &Presenter, clean_before_execution: bool, ) -> Result<(), UseCaseError> { @@ -82,6 +86,14 @@ pub fn execute_command( let _signal_guard = CliSignalGuard::install(cancellation.clone()); match command { Command::Config(_) => unreachable!("config commands are handled outside cli::execute"), + Command::Tools(args) => execute_tools( + config, + args, + required_primary_config_path(primary_config_path)?, + presenter, + clean_before_execution, + cancellation, + ), Command::Init => execute_init(config, presenter, clean_before_execution, cancellation), Command::Extensions(args) => execute_extensions( config, @@ -154,6 +166,9 @@ pub fn execute_command( pub fn command_name(command: &Command) -> CommandName { match command { Command::Config(_) => unreachable!("config commands do not map to execution use cases"), + Command::Tools(ToolsArgs { + command: ToolsCommand::Download(_), + }) => CommandName::ToolsDownload, Command::Init => CommandName::Init, Command::Extensions(_) => CommandName::Extensions, Command::Build(_) => CommandName::Build, @@ -168,6 +183,93 @@ pub fn command_name(command: &Command) -> CommandName { } } +fn execute_tools( + config: &AppConfig, + args: &ToolsArgs, + primary_config_path: PathBuf, + presenter: &Presenter, + clean_before_execution: bool, + cancellation: CancellationToken, +) -> Result<(), UseCaseError> { + match &args.command { + ToolsCommand::Download(download) => execute_tools_download( + config, + download, + primary_config_path, + presenter, + clean_before_execution, + cancellation, + ), + } +} + +fn execute_tools_download( + config: &AppConfig, + args: &ToolsDownloadArgs, + primary_config_path: PathBuf, + presenter: &Presenter, + clean_before_execution: bool, + cancellation: CancellationToken, +) -> Result<(), UseCaseError> { + let request = ToolsDownloadRequest { + config_path: primary_config_path, + extensions: map_tool_extension_mode(&args.extensions), + force: args.force, + }; + let context = cli_context(config, CommandName::ToolsDownload, cancellation); + with_cli_workspace_lock( + config, + presenter, + CommandName::ToolsDownload, + clean_before_execution, + || match tools_download::execute(&context, config, &request) { + Ok(result) => { + if presenter.is_json() { + presenter.print_envelope(&Envelope::ok( + CommandName::ToolsDownload.as_str(), + result.duration_ms, + result, + )); + } else { + render_tools_download_text(&result, presenter); + } + Ok(()) + } + Err(failure) => { + let error = failure.error; + if presenter.is_json() { + match failure.payload { + Some(result) => presenter.print_envelope(&failure_envelope( + CommandName::ToolsDownload.as_str(), + result.duration_ms, + result, + &error, + )), + None => presenter.print_envelope(&pre_dispatch_error_envelope( + CommandName::ToolsDownload.as_str(), + &error, + )), + } + } else { + presenter.print_error(&error.to_string()); + } + Err(error) + } + }, + ) +} + +fn required_primary_config_path( + primary_config_path: Option, +) -> Result { + primary_config_path.ok_or_else(|| { + UseCaseError::new( + UseCaseErrorKind::Validation, + "tools download requires a resolved primary config path", + ) + }) +} + fn execute_extensions( config: &AppConfig, args: &ExtensionsArgs, @@ -711,6 +813,13 @@ fn map_extensions_request(args: &ExtensionsArgs) -> ConfigureExtensionsRequest { } } +fn map_tool_extension_mode(value: &str) -> ToolExtensionInstallMode { + match value { + "artifacts" => ToolExtensionInstallMode::Artifacts, + _ => ToolExtensionInstallMode::Sources, + } +} + fn map_test_request(config: &AppConfig, args: &TestArgs) -> Result { let client_mode = map_test_client_mode(args.client_mode.as_deref())?; match &args.runner { @@ -1471,6 +1580,30 @@ fn render_build_text(result: &BuildResult, presenter: &Presenter, succeeded: boo presenter.print_timeline(&[summary]); } +fn render_tools_download_text(result: &ToolsDownloadResult, presenter: &Presenter) { + let mut details = vec![ + format!("mode: {}", result.mode), + format!("config: {}", result.config_path.display()), + format!("local config: {}", result.local_config_path.display()), + ]; + for destination in &result.destinations { + details.push(format!( + "{} {} -> {} ({})", + destination.tool, + destination.tag, + destination.path.display(), + destination.config + )); + } + + single_timeline( + presenter, + TimelineStatus::Succeeded, + "Tools downloaded successfully", + details, + ); +} + fn timeline_status(ok: bool) -> TimelineStatus { if ok { TimelineStatus::Succeeded @@ -2742,6 +2875,7 @@ mod tests { full_rebuild: true, source_set: None, }), + None, &presenter, false, ) @@ -2773,6 +2907,7 @@ mod tests { scope: TestScope::All, }), }), + None, &presenter, false, ) @@ -2804,6 +2939,7 @@ mod tests { mcp_config: None, mcp_port: None, }), + None, &presenter, false, ) @@ -2836,6 +2972,7 @@ mod tests { }, }), }), + None, &presenter, false, ) @@ -2866,6 +3003,7 @@ mod tests { full_rebuild: true, source_set: None, }), + None, &presenter, true, ) diff --git a/src/config/loader.rs b/src/config/loader.rs index 0d65feb..6c61089 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -5,11 +5,11 @@ use crate::config::model::AppConfig; use crate::config::schema::{ validate_local_overlay_schema_boundary, validate_main_config_schema_boundary, }; -use crate::config::validate::{validate, ConfigValidationError}; +use crate::config::validate::{validate, validate_tools_download_bootstrap, ConfigValidationError}; use crate::support::path::normalize_windows_verbatim_path; -const DEFAULT_CONFIG_FILE_NAME: &str = "v8project.yaml"; -const LOCAL_CONFIG_FILE_NAME: &str = "v8project.local.yaml"; +pub const DEFAULT_CONFIG_FILE_NAME: &str = "v8project.yaml"; +pub const LOCAL_CONFIG_FILE_NAME: &str = "v8project.local.yaml"; #[derive(Debug, Error)] pub enum ConfigLoadError { @@ -44,6 +44,30 @@ pub enum ConfigLoadError { pub fn load_config( config_path: Option<&str>, workdir_override: Option<&str>, +) -> Result { + load_config_with_mode(config_path, workdir_override, ConfigValidationMode::Full) +} + +pub fn load_config_for_tools_download( + config_path: Option<&str>, + workdir_override: Option<&str>, +) -> Result { + load_config_with_mode( + config_path, + workdir_override, + ConfigValidationMode::ToolsDownload, + ) +} + +enum ConfigValidationMode { + Full, + ToolsDownload, +} + +fn load_config_with_mode( + config_path: Option<&str>, + workdir_override: Option<&str>, + validation_mode: ConfigValidationMode, ) -> Result { let path = resolve_config_path(config_path)?; reject_local_overlay_as_primary_config(&path)?; @@ -77,10 +101,21 @@ pub fn load_config( config.work_path = normalize_optional_path(Path::new(wd), config_dir); } - validate(&config)?; + match validation_mode { + ConfigValidationMode::Full => validate(&config)?, + ConfigValidationMode::ToolsDownload => validate_tools_download_bootstrap(&config)?, + } Ok(config) } +pub fn resolve_primary_config_path(config_path: Option<&str>) -> Result { + let path = resolve_config_path(config_path)?; + reject_local_overlay_as_primary_config(&path)?; + Ok(normalize_windows_verbatim_path(&std::fs::canonicalize( + &path, + )?)) +} + fn read_yaml_file(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; Ok(serde_yaml::from_str(&content)?) diff --git a/src/config/validate.rs b/src/config/validate.rs index be37fc7..cd8e0e4 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -202,6 +202,23 @@ pub fn validate(config: &AppConfig) -> Result<(), ConfigValidationError> { Ok(()) } +/// Validate only the configuration parts required to bootstrap downloaded tools. +/// +/// `tools download` may be invoked specifically to create Vanessa Automation and +/// client MCP paths, so those tool-dependent checks must not block the command. +pub fn validate_tools_download_bootstrap(config: &AppConfig) -> Result<(), ConfigValidationError> { + validate_base_path(&config.base_path)?; + validate_work_path(&config.work_path)?; + validate_matrix(config)?; + validate_connection(config)?; + validate_platform_version(config)?; + validate_build_config(config)?; + validate_execution_timeout(config)?; + validate_mcp_config(config)?; + validate_edt_cli_config(config)?; + Ok(()) +} + fn validate_base_path(path: &Path) -> Result<(), ConfigValidationError> { if !path.exists() || !path.is_dir() { return Err(ConfigValidationError::BasePathInvalid( diff --git a/src/domain/mod.rs b/src/domain/mod.rs index bad8f5f..eff9f1d 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -30,3 +30,5 @@ pub mod source_set; pub mod syntax; /// Test domain models. pub mod test; +/// Tool download domain models. +pub mod tools_download; diff --git a/src/domain/tools_download.rs b/src/domain/tools_download.rs new file mode 100644 index 0000000..396a0ef --- /dev/null +++ b/src/domain/tools_download.rs @@ -0,0 +1,28 @@ +use std::path::PathBuf; + +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolExtensionInstallMode { + Sources, + Artifacts, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolsDownloadResult { + pub ok: bool, + pub mode: String, + pub destinations: Vec, + pub config_path: PathBuf, + pub local_config_path: PathBuf, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolDownloadDestination { + pub tool: String, + pub tag: String, + pub source: String, + pub path: PathBuf, + pub config: String, +} diff --git a/src/platform/download.rs b/src/platform/download.rs new file mode 100644 index 0000000..e363316 --- /dev/null +++ b/src/platform/download.rs @@ -0,0 +1,110 @@ +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use thiserror::Error; +use tokio_util::sync::CancellationToken; + +const POLL_INTERVAL: Duration = Duration::from_millis(25); + +#[derive(Debug, Error)] +pub enum DownloadError { + #[error("failed to spawn curl: {0}")] + Spawn(std::io::Error), + + #[error("curl exited with {code}; stderr: {stderr}")] + Failed { code: i32, stderr: String }, + + #[error("curl timed out after {timeout_ms}ms")] + TimedOut { timeout_ms: u64 }, + + #[error("curl was cancelled")] + Cancelled, + + #[error("failed to wait for curl: {0}")] + Wait(std::io::Error), + + #[error("failed to create curl capture file: {0}")] + CaptureFile(std::io::Error), + + #[error("failed to read curl capture file: {0}")] + CaptureRead(std::io::Error), + + #[error("response is not UTF-8: {0}")] + InvalidUtf8(#[from] std::string::FromUtf8Error), +} + +pub fn get_text( + url: &str, + timeout: Option, + cancellation: &CancellationToken, +) -> Result { + let bytes = get_bytes(url, timeout, cancellation)?; + String::from_utf8(bytes).map_err(DownloadError::InvalidUtf8) +} + +pub fn get_bytes( + url: &str, + timeout: Option, + cancellation: &CancellationToken, +) -> Result, DownloadError> { + if timeout.is_some_and(|value| value.is_zero()) { + return Err(DownloadError::TimedOut { timeout_ms: 0 }); + } + let stdout = tempfile::NamedTempFile::new().map_err(DownloadError::CaptureFile)?; + let stderr = tempfile::NamedTempFile::new().map_err(DownloadError::CaptureFile)?; + let started = Instant::now(); + let mut child = Command::new("curl") + .args([ + "-fsSL", + "-H", + "Accept: application/vnd.github+json", + "-H", + "User-Agent: v8-runner", + url, + ]) + .stdout(Stdio::from( + stdout.reopen().map_err(DownloadError::CaptureFile)?, + )) + .stderr(Stdio::from( + stderr.reopen().map_err(DownloadError::CaptureFile)?, + )) + .spawn() + .map_err(DownloadError::Spawn)?; + + loop { + if let Some(status) = child.try_wait().map_err(DownloadError::Wait)? { + if !status.success() { + return Err(DownloadError::Failed { + code: status.code().unwrap_or(-1), + stderr: read_capture_string(stderr.path())?, + }); + } + return std::fs::read(stdout.path()).map_err(DownloadError::CaptureRead); + } + + if cancellation.is_cancelled() { + let _ = child.kill(); + let _ = child.wait(); + return Err(DownloadError::Cancelled); + } + + if let Some(limit) = timeout { + let elapsed = started.elapsed(); + if elapsed >= limit { + let _ = child.kill(); + let _ = child.wait(); + return Err(DownloadError::TimedOut { + timeout_ms: limit.as_millis() as u64, + }); + } + std::thread::sleep(POLL_INTERVAL.min(limit.saturating_sub(elapsed))); + } else { + std::thread::sleep(POLL_INTERVAL); + } + } +} + +fn read_capture_string(path: &std::path::Path) -> Result { + let bytes = std::fs::read(path).map_err(DownloadError::CaptureRead)?; + Ok(String::from_utf8_lossy(&bytes).trim().to_owned()) +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 578d14b..9b49e3f 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,5 +1,6 @@ pub mod connection; pub mod designer; +pub mod download; pub mod edt; pub mod edt_session; pub mod enterprise; diff --git a/src/use_cases/context.rs b/src/use_cases/context.rs index c8da26f..dfdc175 100644 --- a/src/use_cases/context.rs +++ b/src/use_cases/context.rs @@ -7,6 +7,7 @@ use crate::platform::process::{ProcessExecutionPolicy, ProcessInterruptionSafety /// Identifies the logical command being executed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandName { + ToolsDownload, Init, Extensions, Build, @@ -23,6 +24,7 @@ impl CommandName { /// Returns the stable command label used in logs and CLI envelopes. pub const fn as_str(self) -> &'static str { match self { + Self::ToolsDownload => "tools download", Self::Init => "init", Self::Extensions => "extensions", Self::Build => "build", diff --git a/src/use_cases/mod.rs b/src/use_cases/mod.rs index d946807..01ffdcb 100644 --- a/src/use_cases/mod.rs +++ b/src/use_cases/mod.rs @@ -46,6 +46,8 @@ pub(crate) mod source_inventory; mod staged_publication; /// Shared internal preparation for tool extensions. pub(crate) mod tool_extension; +/// Supported external tool download use case. +pub mod tools_download; /// Shared transport-neutral adapter helpers used by CLI and MCP boundaries. pub mod transport; /// Shared Vanessa Automation launch and runtime params helpers. diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index 56bcb9e..8a35f10 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -6,6 +6,7 @@ use crate::domain::runner::{ RunnerProfile, ScenarioExecutionRequest, }; use crate::domain::test::TEST_RUNNER_ID; +use crate::domain::tools_download::ToolExtensionInstallMode; use crate::use_cases::result::{UseCaseError, UseCaseErrorKind}; /// Transport-neutral request for the `build` use case. @@ -17,6 +18,17 @@ pub struct BuildRequest { pub source_set: Option, } +/// Transport-neutral request for the `tools download` use case. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolsDownloadRequest { + /// Canonical path to the primary project config that may be updated. + pub config_path: std::path::PathBuf, + /// Installation mode for extension tools. + pub extensions: ToolExtensionInstallMode, + /// Allows replacing existing downloaded paths. + pub force: bool, +} + /// Transport-neutral request for the `load` use case. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LoadRequest { diff --git a/src/use_cases/tools_download.rs b/src/use_cases/tools_download.rs new file mode 100644 index 0000000..8998aeb --- /dev/null +++ b/src/use_cases/tools_download.rs @@ -0,0 +1,829 @@ +use std::fs::{self, File}; +use std::io::{self, Cursor}; +use std::path::{Component, Path, PathBuf}; +use std::time::Instant; + +use serde::Deserialize; +use zip::ZipArchive; + +use crate::config::loader::LOCAL_CONFIG_FILE_NAME; +use crate::config::model::{AppConfig, BuilderBackend}; +use crate::domain::tools_download::{ + ToolDownloadDestination, ToolExtensionInstallMode, ToolsDownloadResult, +}; +use crate::platform::download; +use crate::support::error::AppError; +use crate::support::fs::{ensure_dir, publish_file_atomically, replace_dir_atomically}; +use crate::use_cases::context::ExecutionContext; +use crate::use_cases::request::ToolsDownloadRequest; +use crate::use_cases::result::{UseCaseFailure, UseCaseResult}; + +const LOCAL_CONFIG_SCHEMA_MODEL_LINE: &str = "# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json"; + +const YAXUNIT_REPO: &str = "bia-technologies/yaxunit"; +const VANESSA_REPO: &str = "Pr-Mex/vanessa-automation-single"; +const CLIENT_MCP_REPO: &str = "1c-neurofish/onec-client-mcp-devkit"; + +const YAXUNIT_SOURCE_PREFIX: &str = "exts/yaxunit/"; +const CLIENT_MCP_SOURCE_PREFIX: &str = "exts/client-mcp/"; +const DOWNLOAD_MARKER_FILE: &str = ".v8-runner-tools-download.json"; + +pub fn execute( + context: &ExecutionContext, + config: &AppConfig, + request: &ToolsDownloadRequest, +) -> UseCaseResult { + tools_download(context, config, request).map_err(|error| UseCaseFailure::without_payload(error)) +} + +fn tools_download( + context: &ExecutionContext, + config: &AppConfig, + request: &ToolsDownloadRequest, +) -> Result { + let started = Instant::now(); + let config_path = request.config_path.clone(); + let config_dir = config_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let local_config_path = config_dir.join(LOCAL_CONFIG_FILE_NAME); + let tools_dir = config.base_path.join("build").join("tools"); + + if request.extensions == ToolExtensionInstallMode::Artifacts + && config.builder != BuilderBackend::Designer + { + return Err(AppError::Validation( + "`tools download --extensions artifacts` requires builder=DESIGNER because client_mcp.cfe is registered as a tool extension artifact; use --extensions sources for builder=IBCMD" + .to_owned(), + )); + } + + ensure_dir(&tools_dir).map_err(|error| { + AppError::Runtime(format!( + "failed to create tools directory '{}': {error}", + tools_dir.display() + )) + })?; + + let mut destinations = Vec::new(); + if request.extensions == ToolExtensionInstallMode::Sources { + validate_yaxunit_source_set_config(&config_path)?; + } + + let yaxunit_release = fetch_latest_release(context, YAXUNIT_REPO)?; + match request.extensions { + ToolExtensionInstallMode::Sources => { + let path = config.base_path.join("tests"); + download_source_subdir( + context, + &yaxunit_release, + YAXUNIT_SOURCE_PREFIX, + &path, + request.force, + )?; + destinations.push(destination( + "yaxunit", + &yaxunit_release, + path, + "source-set tests", + )); + } + ToolExtensionInstallMode::Artifacts => { + let asset = yaxunit_release.required_asset("YAxUnit", ".cfe")?; + let path = tools_dir.join(&asset.name); + download_asset_file(context, asset, &path, request.force)?; + destinations.push(destination("yaxunit", &yaxunit_release, path, "artifact")); + } + } + + let vanessa_release = fetch_latest_release(context, VANESSA_REPO)?; + let vanessa_asset = vanessa_release.required_asset("vanessa-automation-single", ".zip")?; + let vanessa_path = tools_dir.join("vanessa-automation-single.epf"); + download_single_file_from_zip( + context, + vanessa_asset, + "vanessa-automation-single.epf", + &vanessa_path, + request.force, + )?; + destinations.push(destination( + "vanessa-automation-single", + &vanessa_release, + vanessa_path.clone(), + "tools.va.epf_path", + )); + + let client_mcp_release = fetch_latest_release(context, CLIENT_MCP_REPO)?; + match request.extensions { + ToolExtensionInstallMode::Sources => { + let path = tools_dir + .join("onec-client-mcp-devkit") + .join("exts") + .join("client-mcp"); + download_source_subdir( + context, + &client_mcp_release, + CLIENT_MCP_SOURCE_PREFIX, + &path, + request.force, + )?; + destinations.push(destination( + "onec-client-mcp-devkit", + &client_mcp_release, + path, + "tools.client_mcp.extension.source", + )); + } + ToolExtensionInstallMode::Artifacts => { + let asset = client_mcp_release.required_asset("client_mcp", ".cfe")?; + let path = tools_dir.join(&asset.name); + download_asset_file(context, asset, &path, request.force)?; + destinations.push(destination( + "onec-client-mcp-devkit", + &client_mcp_release, + path, + "tools.client_mcp.extension.artifact", + )); + } + } + + update_configs( + context, + &config_path, + &local_config_path, + request.extensions, + &destinations, + )?; + + Ok(ToolsDownloadResult { + ok: true, + mode: mode_label(request.extensions).to_owned(), + destinations, + config_path, + local_config_path, + duration_ms: started.elapsed().as_millis() as u64, + }) +} + +fn fetch_latest_release(context: &ExecutionContext, repo: &str) -> Result { + let base = release_base_url(); + let url = format!("{base}/repos/{repo}/releases/latest"); + let cancellation = context.cancellation(); + let text = + download::get_text(&url, context.remaining_budget(), &cancellation).map_err(|error| { + AppError::Runtime(format!("failed to fetch latest release {repo}: {error}")) + })?; + serde_json::from_str::(&text).map_err(|error| { + AppError::Runtime(format!("failed to parse latest release {repo}: {error}")) + }) +} + +fn release_base_url() -> String { + std::env::var("V8TR_GITHUB_API_BASE_URL") + .unwrap_or_else(|_| "https://api.github.com".to_owned()) + .trim_end_matches('/') + .to_owned() +} + +fn download_asset_file( + context: &ExecutionContext, + asset: &GitHubAsset, + target_path: &Path, + force: bool, +) -> Result<(), AppError> { + if !should_download_file(target_path, force)? { + return Ok(()); + } + let cancellation = context.cancellation(); + let bytes = download::get_bytes( + &asset.browser_download_url, + context.remaining_budget(), + &cancellation, + ) + .map_err(|error| { + AppError::Runtime(format!( + "failed to download asset '{}': {error}", + asset.name + )) + })?; + write_file_download_marker(target_path)?; + publish_bytes(context, &bytes, target_path) +} + +fn download_single_file_from_zip( + context: &ExecutionContext, + asset: &GitHubAsset, + file_name: &str, + target_path: &Path, + force: bool, +) -> Result<(), AppError> { + if !should_download_file(target_path, force)? { + return Ok(()); + } + let cancellation = context.cancellation(); + let bytes = download::get_bytes( + &asset.browser_download_url, + context.remaining_budget(), + &cancellation, + ) + .map_err(|error| { + AppError::Runtime(format!( + "failed to download asset '{}': {error}", + asset.name + )) + })?; + let file = find_file_in_zip(&bytes, file_name)?; + write_file_download_marker(target_path)?; + publish_bytes(context, &file, target_path) +} + +fn download_source_subdir( + context: &ExecutionContext, + release: &GitHubRelease, + source_prefix: &str, + target_path: &Path, + force: bool, +) -> Result<(), AppError> { + if !should_download_source_dir(target_path, force)? { + return Ok(()); + } + let cancellation = context.cancellation(); + let bytes = download::get_bytes( + &release.zipball_url, + context.remaining_budget(), + &cancellation, + ) + .map_err(|error| { + AppError::Runtime(format!( + "failed to download source archive '{}': {error}", + release.zipball_url + )) + })?; + let staged = target_path.with_extension(format!( + "download-{}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + if staged.exists() { + fs::remove_dir_all(&staged).map_err(io_error("failed to cleanup stale staged dir"))?; + } + ensure_dir(&staged).map_err(io_error("failed to create staged source dir"))?; + extract_zip_subdir(&bytes, source_prefix, &staged).inspect_err(|_| { + let _ = fs::remove_dir_all(&staged); + })?; + write_source_download_marker(&staged)?; + + let publish_phase = context.run_no_process_critical_phase(|| { + replace_dir_atomically( + &staged, + target_path, + &chrono::Utc::now() + .timestamp_nanos_opt() + .unwrap_or_default() + .to_string(), + "tools-download", + ".tools-download-backup", + ) + }); + match publish_phase { + Ok(_) => Ok(()), + Err(error) => { + let _ = fs::remove_dir_all(&staged); + Err(AppError::Runtime(format!( + "failed to publish source directory '{}': {error}", + target_path.display() + ))) + } + } +} + +fn find_file_in_zip(bytes: &[u8], file_name: &str) -> Result, AppError> { + let mut archive = ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| AppError::Runtime(format!("failed to read zip archive: {error}")))?; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|error| AppError::Runtime(format!("failed to read zip entry: {error}")))?; + if !file.is_file() { + continue; + } + let Some(name) = Path::new(file.name()) + .file_name() + .and_then(|name| name.to_str()) + else { + continue; + }; + if name == file_name { + let mut bytes = Vec::new(); + io::copy(&mut file, &mut bytes).map_err(|error| { + AppError::Runtime(format!("failed to extract zip entry: {error}")) + })?; + return Ok(bytes); + } + } + Err(AppError::Runtime(format!( + "zip archive does not contain {file_name}" + ))) +} + +fn extract_zip_subdir( + bytes: &[u8], + source_prefix: &str, + target_path: &Path, +) -> Result<(), AppError> { + let mut archive = ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| AppError::Runtime(format!("failed to read zip archive: {error}")))?; + let mut extracted = false; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|error| AppError::Runtime(format!("failed to read zip entry: {error}")))?; + let Some(relative) = zip_relative_path(file.name(), source_prefix) else { + continue; + }; + if relative.as_os_str().is_empty() { + continue; + } + let target = target_path.join(relative); + if file.is_dir() { + ensure_dir(&target).map_err(io_error("failed to create extracted dir"))?; + } else { + if let Some(parent) = target.parent() { + ensure_dir(parent).map_err(io_error("failed to create extracted parent dir"))?; + } + let mut output = + File::create(&target).map_err(io_error("failed to create extracted file"))?; + io::copy(&mut file, &mut output).map_err(io_error("failed to extract zip file"))?; + extracted = true; + } + } + if !extracted { + return Err(AppError::Runtime(format!( + "source archive does not contain {source_prefix}" + ))); + } + Ok(()) +} + +fn zip_relative_path(name: &str, source_prefix: &str) -> Option { + let mut parts = name.splitn(2, '/'); + let _root = parts.next()?; + let inner = parts.next().unwrap_or_default(); + let relative = inner.strip_prefix(source_prefix)?; + if relative.contains('\\') { + return None; + } + let mut safe = PathBuf::new(); + for component in Path::new(relative).components() { + match component { + Component::Normal(value) => safe.push(value), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(safe) +} + +fn publish_bytes( + context: &ExecutionContext, + bytes: &[u8], + target_path: &Path, +) -> Result<(), AppError> { + if let Some(parent) = target_path.parent() { + ensure_dir(parent).map_err(io_error("failed to create target parent dir"))?; + } + let staged = target_path.with_extension(format!( + "download-{}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + fs::write(&staged, bytes).map_err(io_error("failed to write staged file"))?; + let publish_phase = + context.run_no_process_critical_phase(|| publish_file_atomically(&staged, target_path)); + match publish_phase { + Ok(_) => Ok(()), + Err(error) => { + let _ = fs::remove_file(&staged); + Err(AppError::Runtime(format!( + "failed to publish downloaded file '{}': {error}", + target_path.display() + ))) + } + } +} + +fn should_download_file(path: &Path, force: bool) -> Result { + if path.exists() && path.is_dir() { + return Err(AppError::Validation(format!( + "download target is a directory: {}", + path.display() + ))); + } + if !path.exists() { + return Ok(true); + } + if force && !file_download_marker_path(path).exists() { + return Err(AppError::Validation(format!( + "download target already exists and is not managed by v8-runner: {}", + path.display() + ))); + } + Ok(force) +} + +fn should_download_source_dir(path: &Path, force: bool) -> Result { + if !path.exists() { + return Ok(true); + } + if !path.is_dir() { + return Err(AppError::Validation(format!( + "download target is not a directory: {}", + path.display() + ))); + } + if !source_download_marker_path(path).exists() { + return Err(AppError::Validation(format!( + "download target already exists and is not managed by v8-runner: {}", + path.display() + ))); + } + Ok(force) +} + +fn write_source_download_marker(target_path: &Path) -> Result<(), AppError> { + let marker_path = source_download_marker_path(target_path); + let parent = marker_path.parent().ok_or_else(|| { + AppError::Runtime(format!( + "download marker path has no parent: {}", + marker_path.display() + )) + })?; + ensure_dir(parent).map_err(io_error("failed to create download marker parent"))?; + fs::write( + &marker_path, + format!( + "{{\n \"tool\": \"v8-runner\",\n \"target\": \"{}\"\n}}\n", + target_path.display() + ), + ) + .map_err(io_error("failed to write download marker")) +} + +fn write_file_download_marker(target_path: &Path) -> Result<(), AppError> { + let marker_path = file_download_marker_path(target_path); + let parent = marker_path.parent().ok_or_else(|| { + AppError::Runtime(format!( + "download marker path has no parent: {}", + marker_path.display() + )) + })?; + ensure_dir(parent).map_err(io_error("failed to create download marker parent"))?; + fs::write( + &marker_path, + format!( + "{{\n \"tool\": \"v8-runner\",\n \"target\": \"{}\"\n}}\n", + target_path.display() + ), + ) + .map_err(io_error("failed to write download marker")) +} + +fn source_download_marker_path(path: &Path) -> PathBuf { + path.join(DOWNLOAD_MARKER_FILE) +} + +fn file_download_marker_path(path: &Path) -> PathBuf { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let name = path + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "download".to_owned()); + parent.join(format!(".{name}{DOWNLOAD_MARKER_FILE}")) +} + +fn update_configs( + context: &ExecutionContext, + config_path: &Path, + local_config_path: &Path, + mode: ToolExtensionInstallMode, + destinations: &[ToolDownloadDestination], +) -> Result<(), AppError> { + if mode == ToolExtensionInstallMode::Sources { + add_yaxunit_source_set(context, config_path)?; + } + + let local_overlay = render_local_overlay(local_config_path, destinations, mode)?; + publish_bytes(context, local_overlay.as_bytes(), local_config_path) +} + +fn add_yaxunit_source_set(context: &ExecutionContext, config_path: &Path) -> Result<(), AppError> { + let content = fs::read_to_string(config_path).map_err(io_error("failed to read config"))?; + let root: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|error| AppError::Runtime(format!("failed to parse config YAML: {error}")))?; + let mapping = root + .as_mapping() + .ok_or_else(|| AppError::Validation("expected a YAML mapping at config root".to_owned()))?; + let key = serde_yaml::Value::String("source-set".to_owned()); + let source_sets = mapping + .get(&key) + .and_then(serde_yaml::Value::as_sequence) + .ok_or_else(|| AppError::Validation("config must contain source-set list".to_owned()))?; + if source_sets + .iter() + .any(|item| yaml_field_eq(item, "name", "tests")) + { + return Ok(()); + } + + let rendered = insert_yaxunit_source_set_text(&content)?; + publish_bytes(context, rendered.as_bytes(), config_path) +} + +fn validate_yaxunit_source_set_config(config_path: &Path) -> Result<(), AppError> { + let content = fs::read_to_string(config_path).map_err(io_error("failed to read config"))?; + let root: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|error| AppError::Runtime(format!("failed to parse config YAML: {error}")))?; + let mapping = root + .as_mapping() + .ok_or_else(|| AppError::Validation("expected a YAML mapping at config root".to_owned()))?; + let source_sets = mapping + .get(serde_yaml::Value::String("source-set".to_owned())) + .and_then(serde_yaml::Value::as_sequence) + .ok_or_else(|| AppError::Validation("config must contain source-set list".to_owned()))?; + for source_set in source_sets + .iter() + .filter(|item| yaml_field_eq(item, "name", "tests")) + { + let is_expected = yaml_field_eq(source_set, "type", "EXTENSION") + && yaml_field_eq(source_set, "path", "tests"); + if !is_expected { + return Err(AppError::Validation( + "source-set 'tests' already exists but does not match tools download contract: expected type=EXTENSION and path=tests" + .to_owned(), + )); + } + } + Ok(()) +} + +fn insert_yaxunit_source_set_text(content: &str) -> Result { + let source_set_start = content + .lines() + .position(|line| line.trim_end() == "source-set:") + .ok_or_else(|| { + AppError::Validation( + "config source-set list must use block style before tools download can update it" + .to_owned(), + ) + })?; + + let mut insertion_offset = content.len(); + let mut offset = 0usize; + for (index, line) in content.split_inclusive('\n').enumerate() { + let line_start = offset; + offset += line.len(); + if index <= source_set_start { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let is_top_level = line + .chars() + .next() + .is_some_and(|first| !first.is_whitespace()); + if is_top_level { + insertion_offset = line_start; + break; + } + } + + let mut rendered = String::with_capacity(content.len() + 64); + rendered.push_str(&content[..insertion_offset]); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered.push_str(" - name: tests\n type: EXTENSION\n path: tests\n"); + rendered.push_str(&content[insertion_offset..]); + Ok(rendered) +} + +fn render_local_overlay( + path: &Path, + destinations: &[ToolDownloadDestination], + mode: ToolExtensionInstallMode, +) -> Result { + let mut root = if path.exists() { + let content = fs::read_to_string(path).map_err(io_error("failed to read local config"))?; + serde_yaml::from_str::(&content).map_err(|error| { + AppError::Runtime(format!("failed to parse local config YAML: {error}")) + })? + } else { + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) + }; + if root.is_null() { + root = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + + let vanessa_path = destinations + .iter() + .find(|destination| destination.tool == "vanessa-automation-single") + .ok_or_else(|| AppError::Runtime("missing Vanessa download destination".to_owned()))? + .path + .clone(); + let client_path = destinations + .iter() + .find(|destination| destination.tool == "onec-client-mcp-devkit") + .ok_or_else(|| AppError::Runtime("missing client MCP download destination".to_owned()))? + .path + .clone(); + + let root_mapping = root.as_mapping_mut().ok_or_else(|| { + AppError::Validation("expected a YAML mapping at local config root".to_owned()) + })?; + let tools = ensure_mapping(root_mapping, "tools")?; + let va = ensure_mapping(tools, "va")?; + va.insert( + serde_yaml::Value::String("epf_path".to_owned()), + serde_yaml::Value::String(vanessa_path.display().to_string()), + ); + + let client_mcp = ensure_mapping(tools, "client_mcp")?; + let mut extension = serde_yaml::Mapping::new(); + extension.insert( + serde_yaml::Value::String("name".to_owned()), + serde_yaml::Value::String("client_mcp".to_owned()), + ); + match mode { + ToolExtensionInstallMode::Sources => { + let mut source = serde_yaml::Mapping::new(); + source.insert( + serde_yaml::Value::String("path".to_owned()), + serde_yaml::Value::String(client_path.display().to_string()), + ); + source.insert( + serde_yaml::Value::String("format".to_owned()), + serde_yaml::Value::String("EDT".to_owned()), + ); + extension.insert( + serde_yaml::Value::String("source".to_owned()), + serde_yaml::Value::Mapping(source), + ); + } + ToolExtensionInstallMode::Artifacts => { + let mut artifact = serde_yaml::Mapping::new(); + artifact.insert( + serde_yaml::Value::String("path".to_owned()), + serde_yaml::Value::String(client_path.display().to_string()), + ); + extension.insert( + serde_yaml::Value::String("artifact".to_owned()), + serde_yaml::Value::Mapping(artifact), + ); + } + } + client_mcp.insert( + serde_yaml::Value::String("extension".to_owned()), + serde_yaml::Value::Mapping(extension), + ); + + let mut rendered = serde_yaml::to_string(&root).map_err(|error| { + AppError::Runtime(format!("failed to render local config YAML: {error}")) + })?; + rendered = with_local_schema_modeline(&rendered); + Ok(rendered) +} + +fn ensure_mapping<'a>( + parent: &'a mut serde_yaml::Mapping, + key: &str, +) -> Result<&'a mut serde_yaml::Mapping, AppError> { + let key_value = serde_yaml::Value::String(key.to_owned()); + if !parent.contains_key(&key_value) { + parent.insert( + key_value.clone(), + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + ); + } + parent + .get_mut(&key_value) + .and_then(serde_yaml::Value::as_mapping_mut) + .ok_or_else(|| { + AppError::Validation(format!("local config field '{key}' must be a mapping")) + }) +} + +fn yaml_field_eq(value: &serde_yaml::Value, field: &str, expected: &str) -> bool { + value + .as_mapping() + .and_then(|mapping| mapping.get(serde_yaml::Value::String(field.to_owned()))) + .and_then(serde_yaml::Value::as_str) + == Some(expected) +} + +fn with_local_schema_modeline(content: &str) -> String { + let content = content + .lines() + .filter(|line| { + !line + .trim_start() + .starts_with("# yaml-language-server: $schema=") + }) + .collect::>() + .join("\n"); + let mut rendered = format!("{LOCAL_CONFIG_SCHEMA_MODEL_LINE}\n{content}"); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered +} + +fn destination( + tool: &str, + release: &GitHubRelease, + path: PathBuf, + config: &str, +) -> ToolDownloadDestination { + ToolDownloadDestination { + tool: tool.to_owned(), + tag: release.tag_name.clone(), + source: release.html_url.clone(), + path, + config: config.to_owned(), + } +} + +fn mode_label(mode: ToolExtensionInstallMode) -> &'static str { + match mode { + ToolExtensionInstallMode::Sources => "sources", + ToolExtensionInstallMode::Artifacts => "artifacts", + } +} + +fn io_error(context: &'static str) -> impl FnOnce(io::Error) -> AppError { + move |error| AppError::Runtime(format!("{context}: {error}")) +} + +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, + html_url: String, + assets: Vec, + zipball_url: String, +} + +impl GitHubRelease { + fn required_asset( + &self, + name_contains: &str, + extension: &str, + ) -> Result<&GitHubAsset, AppError> { + self.assets + .iter() + .find(|asset| asset.name.contains(name_contains) && asset.name.ends_with(extension)) + .ok_or_else(|| { + AppError::Runtime(format!( + "latest release '{}' does not contain asset matching '*{}*{}'", + self.tag_name, name_contains, extension + )) + }) + } +} + +#[derive(Debug, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zip_relative_path_accepts_safe_source_entry() { + assert_eq!( + zip_relative_path( + "bia-technologies-yaxunit/exts/yaxunit/src/Configuration/Configuration.mdo", + YAXUNIT_SOURCE_PREFIX, + ), + Some(PathBuf::from("src/Configuration/Configuration.mdo")) + ); + } + + #[test] + fn zip_relative_path_rejects_absolute_and_parent_entries() { + assert_eq!( + zip_relative_path("repo/exts/yaxunit//tmp/pwned", YAXUNIT_SOURCE_PREFIX), + None + ); + assert_eq!( + zip_relative_path("repo/exts/yaxunit/../pwned", YAXUNIT_SOURCE_PREFIX), + None + ); + assert_eq!( + zip_relative_path("repo/exts/yaxunit/C:\\temp\\pwned", YAXUNIT_SOURCE_PREFIX,), + None + ); + } +} diff --git a/tests/cli_help.rs b/tests/cli_help.rs index 7ac83c0..7a8a2c1 100644 --- a/tests/cli_help.rs +++ b/tests/cli_help.rs @@ -50,6 +50,21 @@ fn build_help_exposes_source_set_selector() { assert!(stdout.contains("--json-message")); } +#[test] +fn tools_download_help_exposes_extension_install_mode() { + let output = v8_runner_command() + .args(["tools", "download", "--help"]) + .output() + .expect("run command"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Command options:")); + assert!(stdout.contains("Global options:")); + assert!(stdout.contains("--extensions ")); + assert!(stdout.contains("--force")); +} + #[test] fn launch_help_uses_output_path_name_and_global_json_selector() { let output = v8_runner_command() diff --git a/tests/cli_tools_download.rs b/tests/cli_tools_download.rs new file mode 100644 index 0000000..5bacb21 --- /dev/null +++ b/tests/cli_tools_download.rs @@ -0,0 +1,714 @@ +#![cfg(unix)] + +mod support; + +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use support::{free_tcp_port, temp_workspace, v8_runner_command, wait_until}; + +fn write_minimal_config(root: &Path) -> PathBuf { + write_minimal_config_with_builder(root, "DESIGNER") +} + +fn write_minimal_config_with_builder(root: &Path, builder: &str) -> PathBuf { + let base_path = root.join("project"); + let work_path = root.join("work"); + fs::create_dir_all(&base_path).expect("base"); + fs::create_dir_all(base_path.join("configuration")).expect("configuration"); + fs::create_dir_all(&work_path).expect("work"); + let config_path = root.join("v8project.yaml"); + fs::write( + &config_path, + format!( + "# yaml-language-server: $schema=./docs/schemas/v8project.schema.json\nworkPath: '{}'\nformat: DESIGNER\nbuilder: {builder}\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: project/configuration\ntools:\n edt_cli:\n path: /tmp/edt\n", + work_path.display(), + ), + ) + .expect("config"); + config_path +} + +fn write_config_with_pending_va(root: &Path) -> PathBuf { + let config_path = write_minimal_config(root); + let mut config = fs::read_to_string(&config_path).expect("config"); + config.push_str( + "tests:\n va:\n params_path: missing/params.json\n profile: smoke\n profiles:\n smoke:\n feature_path: missing/features\n", + ); + fs::write(&config_path, config).expect("pending va config"); + config_path +} + +fn write_config_with_execution_timeout(root: &Path, timeout_ms: u64) -> PathBuf { + let config_path = write_minimal_config(root); + let mut config = fs::read_to_string(&config_path).expect("config"); + config.push_str(&format!("execution_timeout: {timeout_ms}\n")); + fs::write(&config_path, config).expect("timeout config"); + config_path +} + +fn fixture_server(root: &Path, port: u16) -> Child { + let script = format!( + "import http.server, socketserver\nclass Handler(http.server.SimpleHTTPRequestHandler):\n def do_GET(self):\n redirects = [('/redirect302/', 302), ('/redirect/', 301)]\n for prefix, status in redirects:\n if self.path.startswith(prefix):\n self.send_response(status)\n self.send_header('Location', self.path[len(prefix) - 1:])\n self.end_headers()\n return\n super().do_GET()\nsocketserver.TCPServer.allow_reuse_address = True\nwith socketserver.TCPServer(('127.0.0.1', {port}), Handler) as httpd:\n httpd.serve_forever()\n" + ); + Command::new("python3") + .arg("-c") + .arg(script) + .current_dir(root) + .spawn() + .expect("fixture server") +} + +fn sleeping_server(port: u16) -> Child { + let script = format!( + "import http.server, socketserver, time\nclass Handler(http.server.BaseHTTPRequestHandler):\n def do_GET(self):\n time.sleep(5)\n self.send_response(200)\n self.end_headers()\n self.wfile.write(b'{{}}')\nsocketserver.TCPServer.allow_reuse_address = True\nwith socketserver.TCPServer(('127.0.0.1', {port}), Handler) as httpd:\n httpd.serve_forever()\n" + ); + Command::new("python3") + .arg("-c") + .arg(script) + .spawn() + .expect("sleeping server") +} + +struct FixtureServer { + child: Child, +} + +impl FixtureServer { + fn start(root: &Path, port: u16) -> Self { + Self { + child: fixture_server(root, port), + } + } +} + +impl Drop for FixtureServer { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn write_http_fixture(root: &Path, port: u16) { + write_http_fixture_with_redirect_prefix(root, port, ""); +} + +fn write_http_fixture_with_redirects(root: &Path, port: u16, redirects: bool) { + let prefix = if redirects { "/redirect" } else { "" }; + write_http_fixture_with_redirect_prefix(root, port, prefix); +} + +fn write_http_fixture_with_redirect_prefix(root: &Path, port: u16, prefix: &str) { + let api = root.join("repos"); + write_release( + &api.join("bia-technologies") + .join("yaxunit") + .join("releases"), + "25.12", + &format!("http://127.0.0.1:{port}{prefix}/archives/yaxunit.zip"), + &[( + "YAxUnit-25.12.cfe", + &format!("http://127.0.0.1:{port}{prefix}/assets/YAxUnit-25.12.cfe"), + )], + ); + write_release( + &api.join("Pr-Mex") + .join("vanessa-automation-single") + .join("releases"), + "1.2.043.1", + &format!("http://127.0.0.1:{port}{prefix}/archives/vanessa-source.zip"), + &[( + "vanessa-automation-single.1.2.043.1.zip", + &format!( + "http://127.0.0.1:{port}{prefix}/assets/vanessa-automation-single.1.2.043.1.zip" + ), + )], + ); + write_release( + &api.join("1c-neurofish") + .join("onec-client-mcp-devkit") + .join("releases"), + "v0.6.4", + &format!("http://127.0.0.1:{port}{prefix}/archives/client-mcp.zip"), + &[( + "client_mcp.cfe", + &format!("http://127.0.0.1:{port}{prefix}/assets/client_mcp.cfe"), + )], + ); + + fs::create_dir_all(root.join("assets")).expect("assets"); + fs::write(root.join("assets").join("YAxUnit-25.12.cfe"), "yaxunit cfe").expect("yax asset"); + fs::write(root.join("assets").join("client_mcp.cfe"), "client cfe").expect("client asset"); + make_zip( + &root + .join("assets") + .join("vanessa-automation-single.1.2.043.1.zip"), + &[("vanessa-automation-single.epf", "va epf")], + ); + + fs::create_dir_all(root.join("archives")).expect("archives"); + make_zip( + &root.join("archives").join("yaxunit.zip"), + &[( + "bia-technologies-yaxunit/exts/yaxunit/src/Configuration/Configuration.mdo", + "yaxunit source", + )], + ); + make_zip( + &root.join("archives").join("client-mcp.zip"), + &[( + "1c-neurofish-onec-client-mcp-devkit/exts/client-mcp/src/Configuration/Configuration.mdo", + "client source", + )], + ); + make_zip( + &root.join("archives").join("vanessa-source.zip"), + &[("unused/readme.txt", "unused")], + ); +} + +fn write_release(path: &Path, tag: &str, zipball_url: &str, assets: &[(&str, &str)]) { + fs::create_dir_all(path).expect("release dir"); + let assets_json = assets + .iter() + .map(|(name, url)| format!(r#"{{"name":"{name}","browser_download_url":"{url}"}}"#)) + .collect::>() + .join(","); + fs::write( + path.join("latest"), + format!( + r#"{{"tag_name":"{tag}","html_url":"https://example.invalid/{tag}","zipball_url":"{zipball_url}","assets":[{assets_json}]}}"# + ), + ) + .expect("release json"); +} + +fn make_zip(path: &Path, entries: &[(&str, &str)]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("zip parent"); + } + let status = Command::new("python3") + .arg("-c") + .arg( + "import sys, zipfile\nwith zipfile.ZipFile(sys.argv[1], 'w') as z:\n for pair in sys.argv[2:]:\n name, value = pair.split('=', 1)\n z.writestr(name, value)\n", + ) + .arg(path) + .args(entries.iter().map(|(name, value)| format!("{name}={value}"))) + .status() + .expect("zip"); + assert!(status.success()); +} + +#[test] +fn tools_download_sources_writes_source_set_and_local_tool_settings() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "tools", + "download", + ]) + .output() + .expect("run command"); + let repeat = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + ]) + .output() + .expect("run command again"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + repeat.status.success(), + "status={:?}\nstdout={}\nstderr={}", + repeat.status.code(), + String::from_utf8_lossy(&repeat.stdout), + String::from_utf8_lossy(&repeat.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["command"], "tools download"); + assert_eq!(payload["data"]["mode"], "sources"); + + let config = fs::read_to_string(&config_path).expect("config"); + assert!(config.starts_with("# yaml-language-server:")); + assert!(config.contains("name: tests")); + assert!(config.contains("type: EXTENSION")); + assert!(config.contains("path: tests")); + assert!(dir + .path() + .join("tests/src/Configuration/Configuration.mdo") + .exists()); + assert!(dir + .path() + .join("build/tools/vanessa-automation-single.epf") + .exists()); + assert!(dir + .path() + .join("build/tools/onec-client-mcp-devkit/exts/client-mcp/src/Configuration/Configuration.mdo") + .exists()); + + let local = fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local"); + assert!(local.contains("epf_path:")); + assert!(local.contains("client_mcp:")); + assert!(local.contains("source:")); + assert!(local.contains("format: EDT")); +} + +#[test] +fn tools_download_repairs_pending_vanessa_configuration() { + let dir = temp_workspace(); + let config_path = write_config_with_pending_va(dir.path()); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "--extensions", + "artifacts", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let local = fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local"); + assert!(local.contains("epf_path:")); +} + +#[test] +fn tools_download_follows_latest_release_and_asset_redirects() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture_with_redirects(&server_root, port, true); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}/redirect"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "--extensions", + "artifacts", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(dir.path().join("build/tools/YAxUnit-25.12.cfe").exists()); + assert!(dir + .path() + .join("build/tools/vanessa-automation-single.epf") + .exists()); +} + +#[test] +fn tools_download_follows_302_redirects() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture_with_redirect_prefix(&server_root, port, "/redirect302"); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}/redirect302"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "--extensions", + "artifacts", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(dir.path().join("build/tools/client_mcp.cfe").exists()); +} + +#[test] +fn tools_download_artifacts_keeps_yaxunit_out_of_source_sets() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "--extensions", + "artifacts", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let config = fs::read_to_string(&config_path).expect("config"); + assert!(!config.contains("name: tests")); + assert!(dir.path().join("build/tools/YAxUnit-25.12.cfe").exists()); + assert!(dir.path().join("build/tools/client_mcp.cfe").exists()); + let local = fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local"); + assert!(local.contains("artifact:")); + assert!(local.contains("client_mcp.cfe")); +} + +#[test] +fn tools_download_artifacts_handles_large_assets_without_pipe_deadlock() { + let dir = temp_workspace(); + let config_path = write_config_with_execution_timeout(dir.path(), 3_000); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + fs::write( + server_root.join("assets").join("YAxUnit-25.12.cfe"), + vec![b'x'; 8 * 1024 * 1024], + ) + .expect("large yax asset"); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "--extensions", + "artifacts", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::metadata(dir.path().join("build/tools/YAxUnit-25.12.cfe")) + .expect("large asset") + .len(), + 8 * 1024 * 1024 + ); +} + +#[test] +fn tools_download_sources_refuses_to_replace_unmanaged_tests_dir() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let user_file = dir.path().join("tests/custom.feature"); + fs::create_dir_all(user_file.parent().expect("tests parent")).expect("tests dir"); + fs::write(&user_file, "user content").expect("user test"); + + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "--force", + ]) + .output() + .expect("run command"); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(&user_file).expect("user file"), + "user content" + ); +} + +#[test] +fn tools_download_artifacts_requires_designer_builder() { + let dir = temp_workspace(); + let config_path = write_minimal_config_with_builder(dir.path(), "IBCMD"); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "--extensions", + "artifacts", + ]) + .output() + .expect("run command"); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(combined.contains("requires builder=DESIGNER")); +} + +#[test] +fn tools_download_force_refuses_to_replace_unmanaged_tool_file() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let user_file = dir.path().join("build/tools/vanessa-automation-single.epf"); + fs::create_dir_all(user_file.parent().expect("tools parent")).expect("tools dir"); + fs::write(&user_file, "user epf").expect("user epf"); + + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "--extensions", + "artifacts", + "--force", + ]) + .output() + .expect("run command"); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(&user_file).expect("user file"), + "user epf" + ); +} + +#[test] +fn tools_download_respects_execution_timeout_during_http_download() { + let dir = temp_workspace(); + let config_path = write_config_with_execution_timeout(dir.path(), 200); + let port = free_tcp_port(); + let mut server = sleeping_server(port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let started = std::time::Instant::now(); + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + ]) + .output() + .expect("run command"); + let elapsed = started.elapsed(); + let _ = server.kill(); + let _ = server.wait(); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + elapsed < std::time::Duration::from_secs(2), + "elapsed={elapsed:?}" + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(combined.contains("timed out")); +} + +#[test] +fn tools_download_sources_rejects_conflicting_tests_source_set() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let mut config = fs::read_to_string(&config_path).expect("config"); + config = config.replace( + "tools:\n", + " - name: tests\n type: CONFIGURATION\n path: custom-tests\ntools:\n", + ); + fs::write(&config_path, config).expect("config"); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + ]) + .output() + .expect("run command"); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(combined.contains("source-set 'tests' already exists")); + assert!(!dir.path().join("tests").exists()); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 9954d4e..d63d407 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -93,6 +93,14 @@ pub fn read_line_count(path: &Path) -> usize { .unwrap_or(0) } +pub fn free_tcp_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind free port") + .local_addr() + .expect("local addr") + .port() +} + pub async fn wait_until_async(attempts: usize, interval: Duration, mut condition: F) -> bool where F: FnMut() -> bool, From 11b87bee1f55cb83d0cb90399787568ffe72f41d Mon Sep 17 00:00:00 2001 From: alkoleft Date: Tue, 12 May 2026 00:22:45 +0300 Subject: [PATCH 06/16] feat(tools): split tools download commands - add per-tool download subcommands for yaxunit, vanessa, and client-mcp - replace extension source mode flag with --sources for source-capable tools - improve source archive downloads with codeload URLs, retries, and debug logging --- Cargo.lock | 15 - Cargo.toml | 2 +- README.md | 12 +- SKILL/SKILL.md | 10 +- docs/CAPABILITIES.md | 26 +- docs/CONFIGURATION.md | 13 +- .../arc42/05-building-block-view.md | 2 +- spec/architecture/arc42/06-runtime-view.md | 24 +- src/cli/args.rs | 35 +- src/cli/execute.rs | 41 +- src/domain/tools_download.rs | 8 + src/platform/download.rs | 212 ++++++--- src/use_cases/request.rs | 4 +- src/use_cases/tools_download.rs | 425 +++++++++++++----- tests/cli_help.rs | 22 +- tests/cli_tools_download.rs | 109 ++++- 16 files changed, 686 insertions(+), 274 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0fa8af..81d17e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1634,14 +1634,12 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", "webpki-roots", ] @@ -2532,19 +2530,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasmparser" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index 92c34cb..08c9f3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] } rmcp = { version = "1.2.0", default-features = false, features = ["client", "macros", "server", "transport-child-process", "transport-io", "transport-streamable-http-server"] } tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-util = "0.7" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } [dev-dependencies] assert_cmd = "2" @@ -39,7 +40,6 @@ jsonschema = "0.33" predicates = "3" insta = "1" quote = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } syn = { version = "2", features = ["full"] } [profile.release] diff --git a/README.md b/README.md index 89b4a09..d279f32 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,15 @@ Machine-local пути, credentials и настройки инструменто ### Загрузите тестовые и MCP-инструменты: ```bash -v8-runner tools download +v8-runner tools download yaxunit --sources +v8-runner tools download vanessa +v8-runner tools download client-mcp --sources ``` -По умолчанию команда берёт latest releases YAxUnit, Vanessa Automation single и -onec-client-mcp-devkit, добавляет YAxUnit sources как `source-set` `tests`, кладёт tool artifacts в -`build/tools` и обновляет `v8project.local.yaml`. Для `.cfe`-режима расширений используйте -`v8-runner tools download --extensions artifacts` в проектах с `builder=DESIGNER`. +Команды берут latest releases выбранного инструмента. Для YAxUnit и onec-client-mcp-devkit +`--sources` выбирает source install; без него скачивается `.cfe` artifact в `build/tools`. +Vanessa Automation single всегда скачивается как EPF в `build/tools` и прописывается в +`v8project.local.yaml`. ### Подготовьте рабочую информационную базу: diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 5017ec3..3af7f61 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -56,8 +56,9 @@ v8-runner config init v8-runner config init --connection "File=build/ib" v8-runner config init --format edt v8-runner config init --builder IBCMD -v8-runner tools download -v8-runner tools download --extensions artifacts +v8-runner tools download yaxunit --sources +v8-runner tools download vanessa +v8-runner tools download client-mcp --sources v8-runner init ``` @@ -69,8 +70,9 @@ v8-runner init - Syntax check: inspect `format` and `builder`, then choose `syntax designer-modules`, `syntax designer-config`, or `syntax edt`. - Behavior validation: run the relevant `v8-runner test ...` command; tests build first. - Missing local YAxUnit, Vanessa Automation, or onec-client-mcp-devkit setup: run - `v8-runner tools download` for source-backed extensions, or - `v8-runner tools download --extensions artifacts` for `.cfe`-backed extensions when + `v8-runner tools download yaxunit --sources`, `v8-runner tools download vanessa`, and + `v8-runner tools download client-mcp --sources` for source-backed setup. Omit + `--sources` on `yaxunit` or `client-mcp` to download `.cfe` artifacts when `builder=DESIGNER`. - Vanessa Automation debugging or scenario authoring: use `v8-runner launch mcp va ...` to start the client MCP server with VA loaded. - Extension properties need synchronization: use `v8-runner extensions` or `extensions --name `. diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 781563c..39dad70 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -21,7 +21,7 @@ CLI help, доверяйте текущему коду и затем синхр | Сценарий | Поддерживаемые комбинации | Примечания | | --- | --- | --- | | `config init` | Работает без существующего конфига | Создаёт `v8project.yaml`, sibling `v8project.local.yaml`, `.gitignore` entry, autodetect-ит supported `source-set` и aggregate external roots | -| `tools download` | CLI-only загрузка latest releases | Загружает YAxUnit, Vanessa Automation single и onec-client-mcp-devkit; обновляет local overlay и при source-mode добавляет YAxUnit как `source-set` `tests` | +| `tools download ` | CLI-only загрузка latest releases | Загружает выбранный YAxUnit, Vanessa Automation single или onec-client-mcp-devkit; обновляет local overlay для Vanessa/client MCP и при `yaxunit --sources` добавляет YAxUnit как `source-set` `tests` | | `init` | `format=DESIGNER` + `builder=DESIGNER` | Создаёт файловую ИБ через Designer; server connection остаётся manual prerequisite | | `init` | `format=DESIGNER` + `builder=IBCMD` | Выполняет `ensure` файловой или серверной ИБ через `ibcmd infobase create` | | `init` | `format=EDT` + `builder=DESIGNER|IBCMD` | Готовит ИБ по правилам builder и импортирует EDT workspace | @@ -102,19 +102,23 @@ v8-runner init ### `tools download` ```bash -v8-runner tools download [--extensions ] [--force] +v8-runner tools download yaxunit [--sources] [--force] +v8-runner tools download vanessa [--force] +v8-runner tools download client-mcp [--sources] [--force] ``` - CLI-only; не публикуется как MCP tool. -- Берёт latest release из GitHub для `bia-technologies/yaxunit`, - `Pr-Mex/vanessa-automation-single` и `1c-neurofish/onec-client-mcp-devkit`. -- `--extensions sources` используется по умолчанию: YAxUnit распаковывается в `tests` и - добавляется в primary `v8project.yaml` как `source-set` с именем `tests`, а - onec-client-mcp-devkit распаковывается в `build/tools/onec-client-mcp-devkit/exts/client-mcp`. -- `--extensions artifacts` требует `builder=DESIGNER`, скачивает `.cfe` расширений в - `build/tools` и не меняет список `source-set`. -- Vanessa Automation single всегда скачивается как `build/tools/vanessa-automation-single.epf`. -- `v8project.local.yaml` обновляется machine-local путями для `tools.va.epf_path` и +- Берёт latest release из GitHub для выбранного инструмента: `bia-technologies/yaxunit`, + `Pr-Mex/vanessa-automation-single` или `1c-neurofish/onec-client-mcp-devkit`. +- `yaxunit --sources` распаковывает source subtree в `tests` и добавляет в primary + `v8project.yaml` `source-set` с именем `tests`; без `--sources` скачивает `.cfe` в + `build/tools`. +- `client-mcp --sources` распаковывает source subtree в + `build/tools/onec-client-mcp-devkit/exts/client-mcp`; без `--sources` требует + `builder=DESIGNER` и скачивает `.cfe` в `build/tools`. +- `vanessa` всегда скачивает `build/tools/vanessa-automation-single.epf`. +- `v8project.local.yaml` обновляется только для команд, которым нужны machine-local пути: + `vanessa` заполняет `tools.va.epf_path`, `client-mcp` заполняет `tools.client_mcp.extension`; повторный запуск переиспользует уже скачанные файлы, а `--force` перезаписывает только managed targets, созданные `tools download`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d5865f1..5888148 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -465,12 +465,11 @@ source-set build, а `launch mcp` и `launch mcp va` расширение не неизменёнными исходниками пропускает export/load, а `build --full-rebuild` принудительно обновляет расширение. -`v8-runner tools download` может заполнить этот блок в `v8project.local.yaml`: при -`--extensions sources` он указывает `source.path` на -`build/tools/onec-client-mcp-devkit/exts/client-mcp` и `source.format: EDT`, при -`--extensions artifacts` указывает `artifact.path` на скачанный `client_mcp.cfe`. -Artifact-режим доступен только для `builder=DESIGNER`; для `builder=IBCMD` используйте -source-режим. +`v8-runner tools download client-mcp` может заполнить этот блок в `v8project.local.yaml`: +с `--sources` он указывает `source.path` на +`build/tools/onec-client-mcp-devkit/exts/client-mcp` и `source.format: EDT`, без +`--sources` указывает `artifact.path` на скачанный `client_mcp.cfe`. Artifact-режим +доступен только для `builder=DESIGNER`; для `builder=IBCMD` используйте `--sources`. ### `tools.va` @@ -478,7 +477,7 @@ source-режим. - `epf_path`, путь к внешней обработке Vanessa Automation. -`v8-runner tools download` заполняет `tools.va.epf_path` в `v8project.local.yaml` путём +`v8-runner tools download vanessa` заполняет `tools.va.epf_path` в `v8project.local.yaml` путём `build/tools/vanessa-automation-single.epf`. ## `tools.platform` diff --git a/spec/architecture/arc42/05-building-block-view.md b/spec/architecture/arc42/05-building-block-view.md index 6c32d76..bd08a34 100644 --- a/spec/architecture/arc42/05-building-block-view.md +++ b/spec/architecture/arc42/05-building-block-view.md @@ -46,7 +46,7 @@ flowchart TB - Предоставляет workspace lock helper и internal unlocked entrypoints для nested flows вроде `test -> build`; public lock boundary остаётся в CLI/MCP adapters. - Для runner-like сценариев собирает typed pipeline-like flow и заполняет `ExecutionOutcome` вместо нового ad hoc result shape. - Для `convert` выводит direction из `format`, резолвит `source-set` из `v8project.yaml` и публикует generated output либо под default `workPath/convert/out`, либо под explicit `--output` root с mirror-layout. -- Для `tools download` получает latest release metadata, скачивает выбранные sources/artifacts, обновляет `v8project.local.yaml` и при source-mode добавляет YAxUnit как project `source-set` `tests`. +- Для `tools download ` получает latest release metadata выбранного инструмента, скачивает sources/artifact, обновляет `v8project.local.yaml` для Vanessa/client MCP и при `yaxunit --sources` добавляет YAxUnit как project `source-set` `tests`. #### `mcp` diff --git a/spec/architecture/arc42/06-runtime-view.md b/spec/architecture/arc42/06-runtime-view.md index fc62365..f67eca6 100644 --- a/spec/architecture/arc42/06-runtime-view.md +++ b/spec/architecture/arc42/06-runtime-view.md @@ -75,17 +75,19 @@ sequenceDiagram - CLI adapter получает workspace lock, потому что команда меняет primary config, local overlay и локальные tool directories. -- Use case последовательно читает latest release metadata для YAxUnit, Vanessa Automation single и - onec-client-mcp-devkit. -- Для `--extensions sources` распаковываются source subtrees: YAxUnit в `tests`, client MCP в - `build/tools/onec-client-mcp-devkit/exts/client-mcp`; primary config получает `source-set` - `tests`, если его ещё нет. -- Для `--extensions artifacts` команда требует `builder=DESIGNER`; `.cfe` расширений - скачиваются в `build/tools`, а primary `source-set` не меняется. -- Vanessa Automation single всегда материализуется как `build/tools/vanessa-automation-single.epf`. -- `v8project.local.yaml` обновляется machine-local настройками `tools.va.epf_path` и - `tools.client_mcp.extension`; загрузка не устанавливает расширения в ИБ, не подменяет `build` - и при `--force` заменяет только managed targets, созданные этой командой. +- Use case читает latest release metadata для выбранной команды: `yaxunit`, `vanessa` или + `client-mcp`. +- Для `yaxunit --sources` распаковывается source subtree в `tests`; primary config получает + `source-set` `tests`, если его ещё нет. Без `--sources` скачивается `.cfe` в `build/tools`. +- Для `client-mcp --sources` распаковывается source subtree в + `build/tools/onec-client-mcp-devkit/exts/client-mcp`; без `--sources` команда требует + `builder=DESIGNER` и скачивает `.cfe` в `build/tools`. +- Vanessa Automation single материализуется командой `vanessa` как + `build/tools/vanessa-automation-single.epf`. +- `v8project.local.yaml` обновляется machine-local настройками `tools.va.epf_path` для + `vanessa` и `tools.client_mcp.extension` для `client-mcp`; загрузка не устанавливает + расширения в ИБ, не подменяет `build` и при `--force` заменяет только managed targets, + созданные этой командой. ### 6.5 Сценарий MCP EDT Syntax diff --git a/src/cli/args.rs b/src/cli/args.rs index c057d91..3cb78a0 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -80,17 +80,44 @@ pub struct ToolsArgs { #[derive(Subcommand, Debug)] pub enum ToolsCommand { - /// Download supported test and MCP helper tools from latest GitHub releases + /// Download a supported test or MCP helper tool from its latest GitHub release Download(ToolsDownloadArgs), } #[derive(Args, Debug)] #[command(next_help_heading = "Command options")] pub struct ToolsDownloadArgs { - /// Installation mode for extension tools - #[arg(long, default_value = "sources", value_parser = ["sources", "artifacts"])] - pub extensions: String, + #[command(subcommand)] + pub command: ToolsDownloadCommand, +} + +#[derive(Subcommand, Debug)] +pub enum ToolsDownloadCommand { + /// Download YAxUnit extension assets or sources + Yaxunit(ToolsDownloadExtensionArgs), + /// Download Vanessa Automation Single external processor + #[command(visible_alias = "vanessa-automation-single")] + Vanessa(ToolsDownloadToolArgs), + /// Download onec-client-mcp-devkit extension assets or sources + #[command(name = "client-mcp", visible_alias = "client_mcp")] + ClientMcp(ToolsDownloadExtensionArgs), +} + +#[derive(Args, Debug)] +#[command(next_help_heading = "Command options")] +pub struct ToolsDownloadExtensionArgs { + /// Download extension sources instead of the release artifact + #[arg(long)] + pub sources: bool, + + /// Re-download managed targets created by tools download + #[arg(long)] + pub force: bool, +} +#[derive(Args, Debug)] +#[command(next_help_heading = "Command options")] +pub struct ToolsDownloadToolArgs { /// Re-download managed targets created by tools download #[arg(long)] pub force: bool, diff --git a/src/cli/execute.rs b/src/cli/execute.rs index e2bbaa9..dd6b05f 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -9,7 +9,7 @@ use crate::cli::args::{ ArtifactsArgs, BuildArgs, Command, ConvertArgs, DesignerConfigSyntaxArgs, DesignerModulesSyntaxArgs, DumpArgs, ExtensionsArgs, LaunchArgs, LaunchOptionsArgs, LoadArgs, SyntaxArgs, SyntaxTarget, TestArgs, TestRunner, TestScope, TestVaArgs, TestYaxunitArgs, - ToolsArgs, ToolsCommand, ToolsDownloadArgs, + ToolsArgs, ToolsCommand, ToolsDownloadArgs, ToolsDownloadCommand, }; use crate::cli::output::{ failure_envelope, pre_dispatch_error_envelope, print_command_use_case_error, with_cli_error, @@ -40,7 +40,9 @@ use crate::domain::runner::{ }; use crate::domain::syntax::{SyntaxCheckResult, SyntaxCheckStatus}; use crate::domain::test::{RetainedPaths, TestReport, TestRunResult, TestStatus, TestTarget}; -use crate::domain::tools_download::{ToolExtensionInstallMode, ToolsDownloadResult}; +use crate::domain::tools_download::{ + ToolDownloadTarget, ToolExtensionInstallMode, ToolsDownloadResult, +}; use crate::output::presenter::Presenter; use crate::output::text::{TimelineItem, TimelineStatus}; use crate::support::adapter_input::{ @@ -213,8 +215,9 @@ fn execute_tools_download( ) -> Result<(), UseCaseError> { let request = ToolsDownloadRequest { config_path: primary_config_path, - extensions: map_tool_extension_mode(&args.extensions), - force: args.force, + target: map_tools_download_target(args), + extensions: map_tool_extension_mode(args), + force: map_tools_download_force(args), }; let context = cli_context(config, CommandName::ToolsDownload, cancellation); with_cli_workspace_lock( @@ -813,10 +816,31 @@ fn map_extensions_request(args: &ExtensionsArgs) -> ConfigureExtensionsRequest { } } -fn map_tool_extension_mode(value: &str) -> ToolExtensionInstallMode { - match value { - "artifacts" => ToolExtensionInstallMode::Artifacts, - _ => ToolExtensionInstallMode::Sources, +fn map_tools_download_target(args: &ToolsDownloadArgs) -> ToolDownloadTarget { + match &args.command { + ToolsDownloadCommand::Yaxunit(_) => ToolDownloadTarget::Yaxunit, + ToolsDownloadCommand::Vanessa(_) => ToolDownloadTarget::VanessaAutomationSingle, + ToolsDownloadCommand::ClientMcp(_) => ToolDownloadTarget::ClientMcp, + } +} + +fn map_tool_extension_mode(args: &ToolsDownloadArgs) -> ToolExtensionInstallMode { + match &args.command { + ToolsDownloadCommand::Yaxunit(args) | ToolsDownloadCommand::ClientMcp(args) => { + if args.sources { + ToolExtensionInstallMode::Sources + } else { + ToolExtensionInstallMode::Artifacts + } + } + ToolsDownloadCommand::Vanessa(_) => ToolExtensionInstallMode::Artifacts, + } +} + +fn map_tools_download_force(args: &ToolsDownloadArgs) -> bool { + match &args.command { + ToolsDownloadCommand::Yaxunit(args) | ToolsDownloadCommand::ClientMcp(args) => args.force, + ToolsDownloadCommand::Vanessa(args) => args.force, } } @@ -1582,6 +1606,7 @@ fn render_build_text(result: &BuildResult, presenter: &Presenter, succeeded: boo fn render_tools_download_text(result: &ToolsDownloadResult, presenter: &Presenter) { let mut details = vec![ + format!("tool: {}", result.tool), format!("mode: {}", result.mode), format!("config: {}", result.config_path.display()), format!("local config: {}", result.local_config_path.display()), diff --git a/src/domain/tools_download.rs b/src/domain/tools_download.rs index 396a0ef..3721779 100644 --- a/src/domain/tools_download.rs +++ b/src/domain/tools_download.rs @@ -8,9 +8,17 @@ pub enum ToolExtensionInstallMode { Artifacts, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolDownloadTarget { + Yaxunit, + VanessaAutomationSingle, + ClientMcp, +} + #[derive(Debug, Clone, Serialize)] pub struct ToolsDownloadResult { pub ok: bool, + pub tool: String, pub mode: String, pub destinations: Vec, pub config_path: PathBuf, diff --git a/src/platform/download.rs b/src/platform/download.rs index e363316..e97f91e 100644 --- a/src/platform/download.rs +++ b/src/platform/download.rs @@ -1,33 +1,35 @@ -use std::process::{Command, Stdio}; +use std::io::Read; use std::time::{Duration, Instant}; +use reqwest::blocking::Client; +use reqwest::StatusCode; use thiserror::Error; use tokio_util::sync::CancellationToken; -const POLL_INTERVAL: Duration = Duration::from_millis(25); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const RETRY_ATTEMPTS: usize = 3; +const RETRY_DELAY: Duration = Duration::from_secs(2); +const READ_BUFFER_SIZE: usize = 64 * 1024; #[derive(Debug, Error)] pub enum DownloadError { - #[error("failed to spawn curl: {0}")] - Spawn(std::io::Error), + #[error("HTTP client setup failed: {0}")] + Client(reqwest::Error), - #[error("curl exited with {code}; stderr: {stderr}")] - Failed { code: i32, stderr: String }, + #[error("HTTP GET {url} failed: {source}")] + Request { url: String, source: reqwest::Error }, - #[error("curl timed out after {timeout_ms}ms")] - TimedOut { timeout_ms: u64 }, - - #[error("curl was cancelled")] - Cancelled, + #[error("HTTP GET {url} returned status {status}")] + Status { url: String, status: StatusCode }, - #[error("failed to wait for curl: {0}")] - Wait(std::io::Error), + #[error("HTTP response read failed for {url}: {source}")] + Read { url: String, source: std::io::Error }, - #[error("failed to create curl capture file: {0}")] - CaptureFile(std::io::Error), + #[error("HTTP download timed out after {timeout_ms}ms")] + TimedOut { timeout_ms: u64 }, - #[error("failed to read curl capture file: {0}")] - CaptureRead(std::io::Error), + #[error("HTTP download was cancelled")] + Cancelled, #[error("response is not UTF-8: {0}")] InvalidUtf8(#[from] std::string::FromUtf8Error), @@ -50,61 +52,141 @@ pub fn get_bytes( if timeout.is_some_and(|value| value.is_zero()) { return Err(DownloadError::TimedOut { timeout_ms: 0 }); } - let stdout = tempfile::NamedTempFile::new().map_err(DownloadError::CaptureFile)?; - let stderr = tempfile::NamedTempFile::new().map_err(DownloadError::CaptureFile)?; - let started = Instant::now(); - let mut child = Command::new("curl") - .args([ - "-fsSL", - "-H", - "Accept: application/vnd.github+json", - "-H", - "User-Agent: v8-runner", - url, - ]) - .stdout(Stdio::from( - stdout.reopen().map_err(DownloadError::CaptureFile)?, - )) - .stderr(Stdio::from( - stderr.reopen().map_err(DownloadError::CaptureFile)?, - )) - .spawn() - .map_err(DownloadError::Spawn)?; - loop { - if let Some(status) = child.try_wait().map_err(DownloadError::Wait)? { - if !status.success() { - return Err(DownloadError::Failed { - code: status.code().unwrap_or(-1), - stderr: read_capture_string(stderr.path())?, - }); + let started = Instant::now(); + let mut last_error = None; + + for attempt in 1..=RETRY_ATTEMPTS { + ensure_not_cancelled(cancellation)?; + let request_timeout = remaining_budget(timeout, started)?; + let client = build_client(request_timeout)?; + + match download_once(&client, url, timeout, started, cancellation) { + Ok(bytes) => return Ok(bytes), + Err(error) if attempt < RETRY_ATTEMPTS && error.is_retryable() => { + last_error = Some(error); + sleep_before_retry(cancellation, timeout, started)?; } - return std::fs::read(stdout.path()).map_err(DownloadError::CaptureRead); + Err(error) => return Err(error), } + } - if cancellation.is_cancelled() { - let _ = child.kill(); - let _ = child.wait(); - return Err(DownloadError::Cancelled); - } + Err(last_error.unwrap_or(DownloadError::Cancelled)) +} - if let Some(limit) = timeout { - let elapsed = started.elapsed(); - if elapsed >= limit { - let _ = child.kill(); - let _ = child.wait(); - return Err(DownloadError::TimedOut { - timeout_ms: limit.as_millis() as u64, - }); - } - std::thread::sleep(POLL_INTERVAL.min(limit.saturating_sub(elapsed))); - } else { - std::thread::sleep(POLL_INTERVAL); +fn build_client(timeout: Option) -> Result { + let connect_timeout = timeout + .map(|value| value.min(CONNECT_TIMEOUT)) + .unwrap_or(CONNECT_TIMEOUT); + let mut builder = Client::builder() + .connect_timeout(connect_timeout) + .user_agent("v8-runner"); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + builder.build().map_err(DownloadError::Client) +} + +fn download_once( + client: &Client, + url: &str, + timeout: Option, + started: Instant, + cancellation: &CancellationToken, +) -> Result, DownloadError> { + ensure_not_cancelled(cancellation)?; + let url_text = url.to_owned(); + let mut response = client + .get(url) + .header("Accept", "application/vnd.github+json") + .send() + .map_err(|source| DownloadError::Request { + url: url_text.clone(), + source, + })?; + + if !response.status().is_success() { + return Err(DownloadError::Status { + url: url_text, + status: response.status(), + }); + } + + let capacity = response + .content_length() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or_default(); + let mut bytes = Vec::with_capacity(capacity); + let mut buffer = vec![0_u8; READ_BUFFER_SIZE]; + + loop { + ensure_not_cancelled(cancellation)?; + let _ = remaining_budget(timeout, started)?; + let read = response + .read(&mut buffer) + .map_err(|source| DownloadError::Read { + url: url_text.clone(), + source, + })?; + if read == 0 { + return Ok(bytes); } + bytes.extend_from_slice(&buffer[..read]); + } +} + +fn remaining_budget( + timeout: Option, + started: Instant, +) -> Result, DownloadError> { + let Some(limit) = timeout else { + return Ok(None); + }; + limit + .checked_sub(started.elapsed()) + .filter(|remaining| !remaining.is_zero()) + .map(Some) + .ok_or_else(|| DownloadError::TimedOut { + timeout_ms: limit.as_millis() as u64, + }) +} + +fn sleep_before_retry( + cancellation: &CancellationToken, + timeout: Option, + started: Instant, +) -> Result<(), DownloadError> { + let delay = remaining_budget(timeout, started)? + .map(|remaining| remaining.min(RETRY_DELAY)) + .unwrap_or(RETRY_DELAY); + let until = Instant::now() + delay; + while Instant::now() < until { + ensure_not_cancelled(cancellation)?; + std::thread::sleep( + Duration::from_millis(25).min(until.saturating_duration_since(Instant::now())), + ); } + Ok(()) } -fn read_capture_string(path: &std::path::Path) -> Result { - let bytes = std::fs::read(path).map_err(DownloadError::CaptureRead)?; - Ok(String::from_utf8_lossy(&bytes).trim().to_owned()) +fn ensure_not_cancelled(cancellation: &CancellationToken) -> Result<(), DownloadError> { + if cancellation.is_cancelled() { + Err(DownloadError::Cancelled) + } else { + Ok(()) + } +} + +impl DownloadError { + fn is_retryable(&self) -> bool { + match self { + DownloadError::Request { source, .. } => source.is_timeout() || source.is_connect(), + DownloadError::Read { .. } => true, + DownloadError::Status { status, .. } => status.is_server_error(), + DownloadError::Client(_) + | DownloadError::TimedOut { .. } + | DownloadError::Cancelled + | DownloadError::InvalidUtf8(_) => false, + } + } } diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index 8a35f10..2a8d812 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -6,7 +6,7 @@ use crate::domain::runner::{ RunnerProfile, ScenarioExecutionRequest, }; use crate::domain::test::TEST_RUNNER_ID; -use crate::domain::tools_download::ToolExtensionInstallMode; +use crate::domain::tools_download::{ToolDownloadTarget, ToolExtensionInstallMode}; use crate::use_cases::result::{UseCaseError, UseCaseErrorKind}; /// Transport-neutral request for the `build` use case. @@ -23,6 +23,8 @@ pub struct BuildRequest { pub struct ToolsDownloadRequest { /// Canonical path to the primary project config that may be updated. pub config_path: std::path::PathBuf, + /// Selected tool to download. + pub target: ToolDownloadTarget, /// Installation mode for extension tools. pub extensions: ToolExtensionInstallMode, /// Allows replacing existing downloaded paths. diff --git a/src/use_cases/tools_download.rs b/src/use_cases/tools_download.rs index 8998aeb..602bcc9 100644 --- a/src/use_cases/tools_download.rs +++ b/src/use_cases/tools_download.rs @@ -1,15 +1,17 @@ +use std::ffi::OsString; use std::fs::{self, File}; use std::io::{self, Cursor}; use std::path::{Component, Path, PathBuf}; use std::time::Instant; use serde::Deserialize; +use tracing::debug; use zip::ZipArchive; use crate::config::loader::LOCAL_CONFIG_FILE_NAME; use crate::config::model::{AppConfig, BuilderBackend}; use crate::domain::tools_download::{ - ToolDownloadDestination, ToolExtensionInstallMode, ToolsDownloadResult, + ToolDownloadDestination, ToolDownloadTarget, ToolExtensionInstallMode, ToolsDownloadResult, }; use crate::platform::download; use crate::support::error::AppError; @@ -50,15 +52,6 @@ fn tools_download( let local_config_path = config_dir.join(LOCAL_CONFIG_FILE_NAME); let tools_dir = config.base_path.join("build").join("tools"); - if request.extensions == ToolExtensionInstallMode::Artifacts - && config.builder != BuilderBackend::Designer - { - return Err(AppError::Validation( - "`tools download --extensions artifacts` requires builder=DESIGNER because client_mcp.cfe is registered as a tool extension artifact; use --extensions sources for builder=IBCMD" - .to_owned(), - )); - } - ensure_dir(&tools_dir).map_err(|error| { AppError::Runtime(format!( "failed to create tools directory '{}': {error}", @@ -66,109 +59,147 @@ fn tools_download( )) })?; - let mut destinations = Vec::new(); - if request.extensions == ToolExtensionInstallMode::Sources { - validate_yaxunit_source_set_config(&config_path)?; - } + let destinations = match request.target { + ToolDownloadTarget::Yaxunit => download_yaxunit( + context, + config, + &tools_dir, + request.extensions, + request.force, + &config_path, + )?, + ToolDownloadTarget::VanessaAutomationSingle => { + download_vanessa(context, &tools_dir, request.force)? + } + ToolDownloadTarget::ClientMcp => download_client_mcp( + context, + config, + &tools_dir, + request.extensions, + request.force, + )?, + }; + + update_config_for_download( + context, + &config_path, + &local_config_path, + request.target, + request.extensions, + &destinations, + )?; + + Ok(ToolsDownloadResult { + ok: true, + tool: target_label(request.target).to_owned(), + mode: download_mode_label(request.target, request.extensions).to_owned(), + destinations, + config_path, + local_config_path, + duration_ms: started.elapsed().as_millis() as u64, + }) +} - let yaxunit_release = fetch_latest_release(context, YAXUNIT_REPO)?; - match request.extensions { +fn download_yaxunit( + context: &ExecutionContext, + config: &AppConfig, + tools_dir: &Path, + mode: ToolExtensionInstallMode, + force: bool, + config_path: &Path, +) -> Result, AppError> { + let release = fetch_latest_release(context, YAXUNIT_REPO)?; + match mode { ToolExtensionInstallMode::Sources => { + validate_yaxunit_source_set_config(config_path)?; let path = config.base_path.join("tests"); - download_source_subdir( - context, - &yaxunit_release, - YAXUNIT_SOURCE_PREFIX, - &path, - request.force, - )?; - destinations.push(destination( + download_source_subdir(context, &release, YAXUNIT_SOURCE_PREFIX, &path, force)?; + Ok(vec![destination( "yaxunit", - &yaxunit_release, + &release, path, "source-set tests", - )); + )]) } ToolExtensionInstallMode::Artifacts => { - let asset = yaxunit_release.required_asset("YAxUnit", ".cfe")?; + let asset = release.required_asset("YAxUnit", ".cfe")?; let path = tools_dir.join(&asset.name); - download_asset_file(context, asset, &path, request.force)?; - destinations.push(destination("yaxunit", &yaxunit_release, path, "artifact")); + download_asset_file(context, asset, &path, force)?; + Ok(vec![destination("yaxunit", &release, path, "artifact")]) } } +} - let vanessa_release = fetch_latest_release(context, VANESSA_REPO)?; - let vanessa_asset = vanessa_release.required_asset("vanessa-automation-single", ".zip")?; - let vanessa_path = tools_dir.join("vanessa-automation-single.epf"); +fn download_vanessa( + context: &ExecutionContext, + tools_dir: &Path, + force: bool, +) -> Result, AppError> { + let release = fetch_latest_release(context, VANESSA_REPO)?; + let asset = release.required_asset("vanessa-automation-single", ".zip")?; + let path = tools_dir.join("vanessa-automation-single.epf"); download_single_file_from_zip( context, - vanessa_asset, + asset, "vanessa-automation-single.epf", - &vanessa_path, - request.force, + &path, + force, )?; - destinations.push(destination( + Ok(vec![destination( "vanessa-automation-single", - &vanessa_release, - vanessa_path.clone(), + &release, + path, "tools.va.epf_path", - )); + )]) +} + +fn download_client_mcp( + context: &ExecutionContext, + config: &AppConfig, + tools_dir: &Path, + mode: ToolExtensionInstallMode, + force: bool, +) -> Result, AppError> { + if mode == ToolExtensionInstallMode::Artifacts && config.builder != BuilderBackend::Designer { + return Err(AppError::Validation( + "`tools download client-mcp` requires builder=DESIGNER because client_mcp.cfe is registered as a tool extension artifact; use `tools download client-mcp --sources` for builder=IBCMD" + .to_owned(), + )); + } - let client_mcp_release = fetch_latest_release(context, CLIENT_MCP_REPO)?; - match request.extensions { + let release = fetch_latest_release(context, CLIENT_MCP_REPO)?; + match mode { ToolExtensionInstallMode::Sources => { let path = tools_dir .join("onec-client-mcp-devkit") .join("exts") .join("client-mcp"); - download_source_subdir( - context, - &client_mcp_release, - CLIENT_MCP_SOURCE_PREFIX, - &path, - request.force, - )?; - destinations.push(destination( + download_source_subdir(context, &release, CLIENT_MCP_SOURCE_PREFIX, &path, force)?; + Ok(vec![destination( "onec-client-mcp-devkit", - &client_mcp_release, + &release, path, "tools.client_mcp.extension.source", - )); + )]) } ToolExtensionInstallMode::Artifacts => { - let asset = client_mcp_release.required_asset("client_mcp", ".cfe")?; + let asset = release.required_asset("client_mcp", ".cfe")?; let path = tools_dir.join(&asset.name); - download_asset_file(context, asset, &path, request.force)?; - destinations.push(destination( + download_asset_file(context, asset, &path, force)?; + Ok(vec![destination( "onec-client-mcp-devkit", - &client_mcp_release, + &release, path, "tools.client_mcp.extension.artifact", - )); + )]) } } - - update_configs( - context, - &config_path, - &local_config_path, - request.extensions, - &destinations, - )?; - - Ok(ToolsDownloadResult { - ok: true, - mode: mode_label(request.extensions).to_owned(), - destinations, - config_path, - local_config_path, - duration_ms: started.elapsed().as_millis() as u64, - }) } fn fetch_latest_release(context: &ExecutionContext, repo: &str) -> Result { let base = release_base_url(); let url = format!("{base}/repos/{repo}/releases/latest"); + debug!(repo, url = %url, "fetching latest tool release"); let cancellation = context.cancellation(); let text = download::get_text(&url, context.remaining_budget(), &cancellation).map_err(|error| { @@ -195,6 +226,12 @@ fn download_asset_file( if !should_download_file(target_path, force)? { return Ok(()); } + debug!( + asset = %asset.name, + url = %asset.browser_download_url, + path = %target_path.display(), + "downloading tool asset" + ); let cancellation = context.cancellation(); let bytes = download::get_bytes( &asset.browser_download_url, @@ -221,6 +258,13 @@ fn download_single_file_from_zip( if !should_download_file(target_path, force)? { return Ok(()); } + debug!( + asset = %asset.name, + url = %asset.browser_download_url, + file_name, + path = %target_path.display(), + "downloading tool archive asset" + ); let cancellation = context.cancellation(); let bytes = download::get_bytes( &asset.browser_download_url, @@ -248,18 +292,22 @@ fn download_source_subdir( if !should_download_source_dir(target_path, force)? { return Ok(()); } + let archive_url = source_archive_url(release); + debug!( + tag = %release.tag_name, + url = %archive_url, + source_prefix, + path = %target_path.display(), + "downloading tool source archive" + ); let cancellation = context.cancellation(); - let bytes = download::get_bytes( - &release.zipball_url, - context.remaining_budget(), - &cancellation, - ) - .map_err(|error| { - AppError::Runtime(format!( - "failed to download source archive '{}': {error}", - release.zipball_url - )) - })?; + let bytes = download::get_bytes(&archive_url, context.remaining_budget(), &cancellation) + .map_err(|error| { + AppError::Runtime(format!( + "failed to download source archive '{}': {error}", + archive_url + )) + })?; let staged = target_path.with_extension(format!( "download-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() @@ -271,7 +319,6 @@ fn download_source_subdir( extract_zip_subdir(&bytes, source_prefix, &staged).inspect_err(|_| { let _ = fs::remove_dir_all(&staged); })?; - write_source_download_marker(&staged)?; let publish_phase = context.run_no_process_critical_phase(|| { replace_dir_atomically( @@ -286,7 +333,7 @@ fn download_source_subdir( ) }); match publish_phase { - Ok(_) => Ok(()), + Ok(_) => write_source_download_marker(target_path), Err(error) => { let _ = fs::remove_dir_all(&staged); Err(AppError::Runtime(format!( @@ -488,10 +535,14 @@ fn write_file_download_marker(target_path: &Path) -> Result<(), AppError> { } fn source_download_marker_path(path: &Path) -> PathBuf { - path.join(DOWNLOAD_MARKER_FILE) + sidecar_download_marker_path(path) } fn file_download_marker_path(path: &Path) -> PathBuf { + sidecar_download_marker_path(path) +} + +fn sidecar_download_marker_path(path: &Path) -> PathBuf { let parent = path.parent().unwrap_or_else(|| Path::new(".")); let name = path .file_name() @@ -500,19 +551,80 @@ fn file_download_marker_path(path: &Path) -> PathBuf { parent.join(format!(".{name}{DOWNLOAD_MARKER_FILE}")) } -fn update_configs( +fn relative_path(root: &Path, path: &Path) -> String { + if let Some(relative) = path + .strip_prefix(root) + .ok() + .filter(|relative| !relative.as_os_str().is_empty()) + { + return relative.display().to_string(); + } + + let root_components = normalized_components(root); + let path_components = normalized_components(path); + let common_len = root_components + .iter() + .zip(path_components.iter()) + .take_while(|(left, right)| left == right) + .count(); + + let mut relative = PathBuf::new(); + for _ in common_len..root_components.len() { + relative.push(".."); + } + for component in &path_components[common_len..] { + relative.push(component); + } + + if relative.as_os_str().is_empty() { + ".".to_owned() + } else { + relative.display().to_string() + } +} + +fn normalized_components(path: &Path) -> Vec { + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => components.push(prefix.as_os_str().to_os_string()), + Component::RootDir | Component::CurDir => {} + Component::ParentDir => match components.last() { + Some(last) if last != ".." => { + components.pop(); + } + _ => components.push(OsString::from("..")), + }, + Component::Normal(part) => components.push(part.to_os_string()), + } + } + components +} + +fn update_config_for_download( context: &ExecutionContext, config_path: &Path, local_config_path: &Path, + target: ToolDownloadTarget, mode: ToolExtensionInstallMode, destinations: &[ToolDownloadDestination], ) -> Result<(), AppError> { - if mode == ToolExtensionInstallMode::Sources { - add_yaxunit_source_set(context, config_path)?; + match target { + ToolDownloadTarget::Yaxunit if mode == ToolExtensionInstallMode::Sources => { + add_yaxunit_source_set(context, config_path)?; + } + ToolDownloadTarget::Yaxunit => {} + ToolDownloadTarget::VanessaAutomationSingle => { + let local_overlay = render_vanessa_local_overlay(local_config_path, destinations)?; + publish_bytes(context, local_overlay.as_bytes(), local_config_path)?; + } + ToolDownloadTarget::ClientMcp => { + let local_overlay = + render_client_mcp_local_overlay(local_config_path, destinations, mode)?; + publish_bytes(context, local_overlay.as_bytes(), local_config_path)?; + } } - - let local_overlay = render_local_overlay(local_config_path, destinations, mode)?; - publish_bytes(context, local_overlay.as_bytes(), local_config_path) + Ok(()) } fn add_yaxunit_source_set(context: &ExecutionContext, config_path: &Path) -> Result<(), AppError> { @@ -608,46 +720,51 @@ fn insert_yaxunit_source_set_text(content: &str) -> Result { Ok(rendered) } -fn render_local_overlay( +fn render_vanessa_local_overlay( path: &Path, destinations: &[ToolDownloadDestination], - mode: ToolExtensionInstallMode, ) -> Result { - let mut root = if path.exists() { - let content = fs::read_to_string(path).map_err(io_error("failed to read local config"))?; - serde_yaml::from_str::(&content).map_err(|error| { - AppError::Runtime(format!("failed to parse local config YAML: {error}")) - })? - } else { - serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) - }; - if root.is_null() { - root = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); - } - + let mut root = read_local_overlay(path)?; let vanessa_path = destinations .iter() .find(|destination| destination.tool == "vanessa-automation-single") .ok_or_else(|| AppError::Runtime("missing Vanessa download destination".to_owned()))? .path .clone(); + let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let vanessa_path = relative_path(config_dir, &vanessa_path); + + let root_mapping = root.as_mapping_mut().ok_or_else(|| { + AppError::Validation("expected a YAML mapping at local config root".to_owned()) + })?; + let tools = ensure_mapping(root_mapping, "tools")?; + let va = ensure_mapping(tools, "va")?; + va.insert( + serde_yaml::Value::String("epf_path".to_owned()), + serde_yaml::Value::String(vanessa_path), + ); + render_local_overlay(root) +} + +fn render_client_mcp_local_overlay( + path: &Path, + destinations: &[ToolDownloadDestination], + mode: ToolExtensionInstallMode, +) -> Result { + let mut root = read_local_overlay(path)?; let client_path = destinations .iter() .find(|destination| destination.tool == "onec-client-mcp-devkit") .ok_or_else(|| AppError::Runtime("missing client MCP download destination".to_owned()))? .path .clone(); + let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let client_path = relative_path(config_dir, &client_path); let root_mapping = root.as_mapping_mut().ok_or_else(|| { AppError::Validation("expected a YAML mapping at local config root".to_owned()) })?; let tools = ensure_mapping(root_mapping, "tools")?; - let va = ensure_mapping(tools, "va")?; - va.insert( - serde_yaml::Value::String("epf_path".to_owned()), - serde_yaml::Value::String(vanessa_path.display().to_string()), - ); - let client_mcp = ensure_mapping(tools, "client_mcp")?; let mut extension = serde_yaml::Mapping::new(); extension.insert( @@ -659,7 +776,7 @@ fn render_local_overlay( let mut source = serde_yaml::Mapping::new(); source.insert( serde_yaml::Value::String("path".to_owned()), - serde_yaml::Value::String(client_path.display().to_string()), + serde_yaml::Value::String(client_path.clone()), ); source.insert( serde_yaml::Value::String("format".to_owned()), @@ -674,7 +791,7 @@ fn render_local_overlay( let mut artifact = serde_yaml::Mapping::new(); artifact.insert( serde_yaml::Value::String("path".to_owned()), - serde_yaml::Value::String(client_path.display().to_string()), + serde_yaml::Value::String(client_path), ); extension.insert( serde_yaml::Value::String("artifact".to_owned()), @@ -687,6 +804,25 @@ fn render_local_overlay( serde_yaml::Value::Mapping(extension), ); + render_local_overlay(root) +} + +fn read_local_overlay(path: &Path) -> Result { + let mut root = if path.exists() { + let content = fs::read_to_string(path).map_err(io_error("failed to read local config"))?; + serde_yaml::from_str::(&content).map_err(|error| { + AppError::Runtime(format!("failed to parse local config YAML: {error}")) + })? + } else { + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) + }; + if root.is_null() { + root = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + Ok(root) +} + +fn render_local_overlay(root: serde_yaml::Value) -> Result { let mut rendered = serde_yaml::to_string(&root).map_err(|error| { AppError::Runtime(format!("failed to render local config YAML: {error}")) })?; @@ -760,6 +896,34 @@ fn mode_label(mode: ToolExtensionInstallMode) -> &'static str { } } +fn download_mode_label(target: ToolDownloadTarget, mode: ToolExtensionInstallMode) -> &'static str { + match target { + ToolDownloadTarget::VanessaAutomationSingle => "epf", + ToolDownloadTarget::Yaxunit | ToolDownloadTarget::ClientMcp => mode_label(mode), + } +} + +fn target_label(target: ToolDownloadTarget) -> &'static str { + match target { + ToolDownloadTarget::Yaxunit => "yaxunit", + ToolDownloadTarget::VanessaAutomationSingle => "vanessa", + ToolDownloadTarget::ClientMcp => "client-mcp", + } +} + +fn source_archive_url(release: &GitHubRelease) -> String { + let Some(rest) = release + .zipball_url + .strip_prefix("https://api.github.com/repos/") + else { + return release.zipball_url.clone(); + }; + let Some((repo, tag)) = rest.split_once("/zipball/") else { + return release.zipball_url.clone(); + }; + format!("https://codeload.github.com/{repo}/zip/refs/tags/{tag}") +} + fn io_error(context: &'static str) -> impl FnOnce(io::Error) -> AppError { move |error| AppError::Runtime(format!("{context}: {error}")) } @@ -826,4 +990,35 @@ mod tests { None ); } + + #[test] + fn source_archive_url_uses_codeload_for_github_zipball() { + let release = GitHubRelease { + tag_name: "25.12".to_owned(), + html_url: "https://github.com/bia-technologies/yaxunit/releases/tag/25.12".to_owned(), + assets: Vec::new(), + zipball_url: "https://api.github.com/repos/bia-technologies/yaxunit/zipball/25.12" + .to_owned(), + }; + + assert_eq!( + source_archive_url(&release), + "https://codeload.github.com/bia-technologies/yaxunit/zip/refs/tags/25.12" + ); + } + + #[test] + fn source_archive_url_keeps_test_or_custom_urls() { + let release = GitHubRelease { + tag_name: "test".to_owned(), + html_url: "https://example.invalid/test".to_owned(), + assets: Vec::new(), + zipball_url: "http://127.0.0.1:1234/archive.zip".to_owned(), + }; + + assert_eq!( + source_archive_url(&release), + "http://127.0.0.1:1234/archive.zip" + ); + } } diff --git a/tests/cli_help.rs b/tests/cli_help.rs index 7a8a2c1..430d433 100644 --- a/tests/cli_help.rs +++ b/tests/cli_help.rs @@ -51,7 +51,7 @@ fn build_help_exposes_source_set_selector() { } #[test] -fn tools_download_help_exposes_extension_install_mode() { +fn tools_download_help_exposes_tool_commands() { let output = v8_runner_command() .args(["tools", "download", "--help"]) .output() @@ -59,9 +59,25 @@ fn tools_download_help_exposes_extension_install_mode() { assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Command options:")); assert!(stdout.contains("Global options:")); - assert!(stdout.contains("--extensions ")); + assert!(stdout.contains("Commands:")); + assert!(stdout.contains("yaxunit")); + assert!(stdout.contains("vanessa")); + assert!(stdout.contains("client-mcp")); + assert!(!stdout.contains("--extensions")); +} + +#[test] +fn tools_download_extension_help_exposes_sources_flag() { + let output = v8_runner_command() + .args(["tools", "download", "yaxunit", "--help"]) + .output() + .expect("run command"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Command options:")); + assert!(stdout.contains("--sources")); assert!(stdout.contains("--force")); } diff --git a/tests/cli_tools_download.rs b/tests/cli_tools_download.rs index 5bacb21..efec7c9 100644 --- a/tests/cli_tools_download.rs +++ b/tests/cli_tools_download.rs @@ -225,6 +225,8 @@ fn tools_download_sources_writes_source_set_and_local_tool_settings() { "--json-message", "tools", "download", + "yaxunit", + "--sources", ]) .output() .expect("run command"); @@ -238,9 +240,40 @@ fn tools_download_sources_writes_source_set_and_local_tool_settings() { &config_path.display().to_string(), "tools", "download", + "yaxunit", + "--sources", ]) .output() .expect("run command again"); + let client_mcp = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "client-mcp", + "--sources", + ]) + .output() + .expect("run client-mcp command"); + let vanessa = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "vanessa", + ]) + .output() + .expect("run vanessa command"); assert!( output.status.success(), @@ -256,8 +289,23 @@ fn tools_download_sources_writes_source_set_and_local_tool_settings() { String::from_utf8_lossy(&repeat.stdout), String::from_utf8_lossy(&repeat.stderr) ); + assert!( + client_mcp.status.success(), + "status={:?}\nstdout={}\nstderr={}", + client_mcp.status.code(), + String::from_utf8_lossy(&client_mcp.stdout), + String::from_utf8_lossy(&client_mcp.stderr) + ); + assert!( + vanessa.status.success(), + "status={:?}\nstdout={}\nstderr={}", + vanessa.status.code(), + String::from_utf8_lossy(&vanessa.stdout), + String::from_utf8_lossy(&vanessa.stderr) + ); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!(payload["command"], "tools download"); + assert_eq!(payload["data"]["tool"], "yaxunit"); assert_eq!(payload["data"]["mode"], "sources"); let config = fs::read_to_string(&config_path).expect("config"); @@ -269,6 +317,14 @@ fn tools_download_sources_writes_source_set_and_local_tool_settings() { .path() .join("tests/src/Configuration/Configuration.mdo") .exists()); + assert!(!dir + .path() + .join("tests/.v8-runner-tools-download.json") + .exists()); + assert!(dir + .path() + .join(".tests.v8-runner-tools-download.json") + .exists()); assert!(dir .path() .join("build/tools/vanessa-automation-single.epf") @@ -277,12 +333,23 @@ fn tools_download_sources_writes_source_set_and_local_tool_settings() { .path() .join("build/tools/onec-client-mcp-devkit/exts/client-mcp/src/Configuration/Configuration.mdo") .exists()); + assert!(!dir + .path() + .join("build/tools/onec-client-mcp-devkit/exts/client-mcp/.v8-runner-tools-download.json") + .exists()); + assert!(dir + .path() + .join("build/tools/onec-client-mcp-devkit/exts/.client-mcp.v8-runner-tools-download.json") + .exists()); let local = fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local"); assert!(local.contains("epf_path:")); + assert!(local.contains("epf_path: build/tools/vanessa-automation-single.epf")); assert!(local.contains("client_mcp:")); assert!(local.contains("source:")); + assert!(local.contains("path: build/tools/onec-client-mcp-devkit/exts/client-mcp")); assert!(local.contains("format: EDT")); + assert!(!local.contains(&dir.path().display().to_string())); } #[test] @@ -309,8 +376,7 @@ fn tools_download_repairs_pending_vanessa_configuration() { &config_path.display().to_string(), "tools", "download", - "--extensions", - "artifacts", + "vanessa", ]) .output() .expect("run command"); @@ -324,6 +390,8 @@ fn tools_download_repairs_pending_vanessa_configuration() { ); let local = fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local"); assert!(local.contains("epf_path:")); + assert!(local.contains("epf_path: build/tools/vanessa-automation-single.epf")); + assert!(!local.contains(&dir.path().display().to_string())); } #[test] @@ -350,8 +418,7 @@ fn tools_download_follows_latest_release_and_asset_redirects() { &config_path.display().to_string(), "tools", "download", - "--extensions", - "artifacts", + "client-mcp", ]) .output() .expect("run command"); @@ -363,11 +430,10 @@ fn tools_download_follows_latest_release_and_asset_redirects() { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert!(dir.path().join("build/tools/YAxUnit-25.12.cfe").exists()); - assert!(dir - .path() - .join("build/tools/vanessa-automation-single.epf") - .exists()); + assert!(dir.path().join("build/tools/client_mcp.cfe").exists()); + let local = fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local"); + assert!(local.contains("artifact:")); + assert!(local.contains("client_mcp.cfe")); } #[test] @@ -394,8 +460,7 @@ fn tools_download_follows_302_redirects() { &config_path.display().to_string(), "tools", "download", - "--extensions", - "artifacts", + "client-mcp", ]) .output() .expect("run command"); @@ -434,8 +499,7 @@ fn tools_download_artifacts_keeps_yaxunit_out_of_source_sets() { &config_path.display().to_string(), "tools", "download", - "--extensions", - "artifacts", + "yaxunit", ]) .output() .expect("run command"); @@ -450,10 +514,7 @@ fn tools_download_artifacts_keeps_yaxunit_out_of_source_sets() { let config = fs::read_to_string(&config_path).expect("config"); assert!(!config.contains("name: tests")); assert!(dir.path().join("build/tools/YAxUnit-25.12.cfe").exists()); - assert!(dir.path().join("build/tools/client_mcp.cfe").exists()); - let local = fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local"); - assert!(local.contains("artifact:")); - assert!(local.contains("client_mcp.cfe")); + assert!(!dir.path().join("v8project.local.yaml").exists()); } #[test] @@ -485,8 +546,7 @@ fn tools_download_artifacts_handles_large_assets_without_pipe_deadlock() { &config_path.display().to_string(), "tools", "download", - "--extensions", - "artifacts", + "yaxunit", ]) .output() .expect("run command"); @@ -534,6 +594,8 @@ fn tools_download_sources_refuses_to_replace_unmanaged_tests_dir() { &config_path.display().to_string(), "tools", "download", + "yaxunit", + "--sources", "--force", ]) .output() @@ -562,8 +624,7 @@ fn tools_download_artifacts_requires_designer_builder() { &config_path.display().to_string(), "tools", "download", - "--extensions", - "artifacts", + "client-mcp", ]) .output() .expect("run command"); @@ -610,8 +671,7 @@ fn tools_download_force_refuses_to_replace_unmanaged_tool_file() { &config_path.display().to_string(), "tools", "download", - "--extensions", - "artifacts", + "vanessa", "--force", ]) .output() @@ -652,6 +712,7 @@ fn tools_download_respects_execution_timeout_during_http_download() { &config_path.display().to_string(), "tools", "download", + "yaxunit", ]) .output() .expect("run command"); @@ -694,6 +755,8 @@ fn tools_download_sources_rejects_conflicting_tests_source_set() { &config_path.display().to_string(), "tools", "download", + "yaxunit", + "--sources", ]) .output() .expect("run command"); From 9212d13d1f5bc39300b8c7ae84e187a228fe1e2f Mon Sep 17 00:00:00 2001 From: alkoleft Date: Tue, 12 May 2026 00:55:20 +0300 Subject: [PATCH 07/16] feat(cli): clarify init import and marker paths - show EDT source-set project names during init import - store YAxUnit sources download marker under build only --- src/use_cases/init_project.rs | 18 ++++++++- src/use_cases/tools_download.rs | 39 +++++++++++++++---- tests/cli_init.rs | 4 ++ tests/cli_tools_download.rs | 66 ++++++++++++++++++++++++++++++++- 4 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/use_cases/init_project.rs b/src/use_cases/init_project.rs index 5b889e3..a05f711 100644 --- a/src/use_cases/init_project.rs +++ b/src/use_cases/init_project.rs @@ -407,6 +407,7 @@ fn ensure_edt_workspace( ) }; debug!("[EDT] Инициализация workspace: {}", workspace.display()); + let mut imported_projects = Vec::new(); for project in projects { if let Some(outcome) = interruption_step_outcome( context, @@ -418,7 +419,10 @@ fn ensure_edt_workspace( return outcome; } debug!("[EDT] Импорт проекта: {}", project.name); - log_live_stage("init: edt import", "[EDT] importing source-set project"); + log_live_stage( + "init: edt import", + &format!("[EDT] importing source-set project '{}'", project.name), + ); match dsl.import_project(&project.path) { Ok(result) => { if let Err(error) = @@ -436,6 +440,7 @@ fn ensure_edt_workspace( ) } } + imported_projects.push(project.name); } if let Err(error) = std::fs::write(&marker, b"initialized\n") { @@ -455,12 +460,21 @@ fn ensure_edt_workspace( "import", started, with_optional_warning( - format!("workspace initialized: {}", workspace.display()), + edt_workspace_initialized_message(&workspace, &imported_projects), context_deferred_warning(context), ), ) } +fn edt_workspace_initialized_message(workspace: &Path, imported_projects: &[String]) -> String { + let mut message = format!("workspace initialized: {}", workspace.display()); + if !imported_projects.is_empty() { + message.push_str("; imported EDT projects: "); + message.push_str(&imported_projects.join(", ")); + } + message +} + fn create_infobase_via_designer( context: &ExecutionContext, config: &AppConfig, diff --git a/src/use_cases/tools_download.rs b/src/use_cases/tools_download.rs index 602bcc9..ceccc99 100644 --- a/src/use_cases/tools_download.rs +++ b/src/use_cases/tools_download.rs @@ -113,7 +113,18 @@ fn download_yaxunit( ToolExtensionInstallMode::Sources => { validate_yaxunit_source_set_config(config_path)?; let path = config.base_path.join("tests"); - download_source_subdir(context, &release, YAXUNIT_SOURCE_PREFIX, &path, force)?; + let marker_path = config + .base_path + .join("build") + .join(format!(".tests{DOWNLOAD_MARKER_FILE}")); + download_source_subdir( + context, + &release, + YAXUNIT_SOURCE_PREFIX, + &path, + &marker_path, + force, + )?; Ok(vec![destination( "yaxunit", &release, @@ -174,7 +185,15 @@ fn download_client_mcp( .join("onec-client-mcp-devkit") .join("exts") .join("client-mcp"); - download_source_subdir(context, &release, CLIENT_MCP_SOURCE_PREFIX, &path, force)?; + let marker_path = source_download_marker_path(&path); + download_source_subdir( + context, + &release, + CLIENT_MCP_SOURCE_PREFIX, + &path, + &marker_path, + force, + )?; Ok(vec![destination( "onec-client-mcp-devkit", &release, @@ -287,9 +306,10 @@ fn download_source_subdir( release: &GitHubRelease, source_prefix: &str, target_path: &Path, + marker_path: &Path, force: bool, ) -> Result<(), AppError> { - if !should_download_source_dir(target_path, force)? { + if !should_download_source_dir(target_path, marker_path, force)? { return Ok(()); } let archive_url = source_archive_url(release); @@ -333,7 +353,7 @@ fn download_source_subdir( ) }); match publish_phase { - Ok(_) => write_source_download_marker(target_path), + Ok(_) => write_source_download_marker(target_path, marker_path), Err(error) => { let _ = fs::remove_dir_all(&staged); Err(AppError::Runtime(format!( @@ -477,7 +497,11 @@ fn should_download_file(path: &Path, force: bool) -> Result { Ok(force) } -fn should_download_source_dir(path: &Path, force: bool) -> Result { +fn should_download_source_dir( + path: &Path, + marker_path: &Path, + force: bool, +) -> Result { if !path.exists() { return Ok(true); } @@ -487,7 +511,7 @@ fn should_download_source_dir(path: &Path, force: bool) -> Result Result Result<(), AppError> { - let marker_path = source_download_marker_path(target_path); +fn write_source_download_marker(target_path: &Path, marker_path: &Path) -> Result<(), AppError> { let parent = marker_path.parent().ok_or_else(|| { AppError::Runtime(format!( "download marker path has no parent: {}", diff --git a/tests/cli_init.rs b/tests/cli_init.rs index 542f663..03179fa 100644 --- a/tests/cli_init.rs +++ b/tests/cli_init.rs @@ -328,6 +328,10 @@ fn init_edt_with_ibcmd_creates_infobase_and_imports_projects_in_order() { .trim_matches('\''); assert!(Path::new(infobase_dir).join("1Cv8.1CD").exists()); assert!(work_path.join("edt-workspace").exists()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("importing source-set project 'main'")); + assert!(stdout.contains("importing source-set project 'ext'")); + assert!(stdout.contains("imported EDT projects: main, ext")); let calls = fs::read_to_string(edt_calls_log).expect("calls"); let lines: Vec<_> = calls.lines().collect(); assert_eq!(lines.len(), 2); diff --git a/tests/cli_tools_download.rs b/tests/cli_tools_download.rs index efec7c9..5cc724e 100644 --- a/tests/cli_tools_download.rs +++ b/tests/cli_tools_download.rs @@ -321,10 +321,14 @@ fn tools_download_sources_writes_source_set_and_local_tool_settings() { .path() .join("tests/.v8-runner-tools-download.json") .exists()); - assert!(dir + assert!(!dir .path() .join(".tests.v8-runner-tools-download.json") .exists()); + assert!(dir + .path() + .join("build/.tests.v8-runner-tools-download.json") + .exists()); assert!(dir .path() .join("build/tools/vanessa-automation-single.epf") @@ -352,6 +356,66 @@ fn tools_download_sources_writes_source_set_and_local_tool_settings() { assert!(!local.contains(&dir.path().display().to_string())); } +#[test] +fn tools_download_sources_rejects_legacy_tests_markers_outside_build() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + fs::create_dir_all(dir.path().join("tests")).expect("tests"); + fs::write( + dir.path().join(".tests.v8-runner-tools-download.json"), + "{}\n", + ) + .expect("legacy root marker"); + fs::write( + dir.path().join("tests/.v8-runner-tools-download.json"), + "{}\n", + ) + .expect("legacy nested marker"); + + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "yaxunit", + "--sources", + ]) + .output() + .expect("run command"); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(combined.contains("download target already exists and is not managed by v8-runner")); + assert!(!dir + .path() + .join("build/.tests.v8-runner-tools-download.json") + .exists()); +} + #[test] fn tools_download_repairs_pending_vanessa_configuration() { let dir = temp_workspace(); From 5b486da7380cf93f1bd81ebd32b432edaa89137f Mon Sep 17 00:00:00 2001 From: alkoleft Date: Tue, 12 May 2026 01:25:43 +0300 Subject: [PATCH 08/16] fix(init): report step status live - emit init step completion status as live timeline events - cover infobase failure before continued EDT import --- src/use_cases/init_project.rs | 49 ++++++++++++++++++++++++++++++----- src/use_cases/progress.rs | 25 ++++++++++++++++++ tests/cli_init.rs | 34 ++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/use_cases/init_project.rs b/src/use_cases/init_project.rs index a05f711..86fbf52 100644 --- a/src/use_cases/init_project.rs +++ b/src/use_cases/init_project.rs @@ -21,7 +21,7 @@ use crate::platform::utilities::PlatformUtilities; use crate::support::error::AppError; use crate::use_cases::context::{ExecutionContext, InterruptionSafetyClass}; use crate::use_cases::interruption; -use crate::use_cases::progress::log_live_stage; +use crate::use_cases::progress::{log_live_stage, log_live_stage_status, LiveStageStatus}; use crate::use_cases::request::InitRequest; use crate::use_cases::result::{UseCaseError, UseCaseFailure, UseCaseResult}; use crate::use_cases::tool_extension; @@ -59,11 +59,7 @@ fn run_init(context: &ExecutionContext, config: &AppConfig) -> UseCaseResult Err(InitExecutionFailure::with_payload(error, result)), @@ -71,6 +67,14 @@ fn run_init(context: &ExecutionContext, config: &AppConfig) -> UseCaseResult, ok: bool) -> InitResult { + InitResult { + ok, + steps, + duration_ms: started.elapsed().as_millis() as u64, + } +} + fn record_step( steps: &mut Vec, first_error: &mut Option, @@ -79,9 +83,42 @@ fn record_step( if first_error.is_none() { *first_error = outcome.error.clone(); } + log_step_status(&outcome.step); steps.push(outcome.step); } +fn log_step_status(step: &InitStep) { + let Some(label) = live_step_label(step) else { + return; + }; + let Some((status, marker)) = live_status_marker(&step.status) else { + return; + }; + log_live_stage_status( + label, + status, + &format!("{marker} {}: {}", step.target, step.action), + ); +} + +fn live_step_label(step: &InitStep) -> Option<&'static str> { + match (step.target.as_str(), step.action.as_str()) { + ("infobase", "create") => Some("init: infobase create"), + ("edt_workspace", "import") if !matches!(step.status, InitStepStatus::Skipped) => { + Some("init: edt import") + } + _ => None, + } +} + +fn live_status_marker(status: &InitStepStatus) -> Option<(LiveStageStatus, &'static str)> { + match status { + InitStepStatus::Ok => Some((LiveStageStatus::Succeeded, "✓")), + InitStepStatus::Failed => Some((LiveStageStatus::Failed, "✗")), + InitStepStatus::Skipped => None, + } +} + #[derive(Debug, Clone)] struct StepOutcome { step: InitStep, diff --git a/src/use_cases/progress.rs b/src/use_cases/progress.rs index 27506f8..282c46b 100644 --- a/src/use_cases/progress.rs +++ b/src/use_cases/progress.rs @@ -1,5 +1,20 @@ use tracing::info; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LiveStageStatus { + Succeeded, + Failed, +} + +impl LiveStageStatus { + fn as_str(self) -> &'static str { + match self { + Self::Succeeded => "succeeded", + Self::Failed => "failed", + } + } +} + /// Emits a text-mode live progress timeline event before a blocking stage starts. /// /// Callers must pass fixed, sanitized vocabulary only: no rendered commands, raw @@ -12,3 +27,13 @@ pub(crate) fn log_live_stage(label: &str, detail: &str) { timeline_detail = detail ); } + +/// Emits a sanitized live progress status after a blocking stage finishes. +pub(crate) fn log_live_stage_status(label: &str, status: LiveStageStatus, detail: &str) { + info!( + target: "v8_runner::live_progress", + timeline_status = status.as_str(), + timeline_label = label, + timeline_detail = detail + ); +} diff --git a/tests/cli_init.rs b/tests/cli_init.rs index 03179fa..7f7103f 100644 --- a/tests/cli_init.rs +++ b/tests/cli_init.rs @@ -251,6 +251,40 @@ fn init_designer_non_zero_create_exit_stays_fatal_even_when_marker_appears() { .contains("designer create failed")); } +#[test] +fn init_text_reports_infobase_failure_before_continuing_edt_import() { + let (_dir, config_path, _work_path, _base_path, platform_path, edt_calls_log) = + setup_edt_init_project("EDT", "DESIGNER", "__AUTO_FILE__"); + write_script( + &platform_path, + "printf 'designer create failed\\n' >&2\nexit 1", + ); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--no-color", + "init", + ]) + .output() + .expect("run command"); + + assert!(!output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let failed_step = stdout + .find("✗ infobase: create") + .expect("live failed infobase status"); + let edt_import = stdout + .find("importing source-set project") + .expect("continued edt import"); + let final_summary = stdout.find("Init failed").expect("final summary"); + assert!(failed_step < edt_import); + assert!(failed_step < final_summary); + assert!(stdout.contains("✓ edt_workspace: import")); + assert!(edt_calls_log.exists()); +} + #[test] fn init_ibcmd_creates_infobase_and_imports_edt_projects_in_order() { let (_dir, config_path, work_path, _base_path, _platform_path, edt_calls_log) = From 04f089ca96296feda1ff40c7d439f6367c04cb33 Mon Sep 17 00:00:00 2001 From: alkoleft Date: Tue, 12 May 2026 02:25:25 +0300 Subject: [PATCH 09/16] fix(tools): harden download publication - keep managed markers consistent with published targets - bound HTTP download responses and document limits --- SKILL/references/troubleshooting.md | 2 + docs/CAPABILITIES.md | 4 ++ spec/architecture/arc42/06-runtime-view.md | 5 ++ src/platform/download.rs | 63 +++++++++++++++++++++- src/use_cases/tools_download.rs | 36 ++++++++++--- 5 files changed, 102 insertions(+), 8 deletions(-) diff --git a/SKILL/references/troubleshooting.md b/SKILL/references/troubleshooting.md index 0a89c93..6891ef1 100644 --- a/SKILL/references/troubleshooting.md +++ b/SKILL/references/troubleshooting.md @@ -25,6 +25,8 @@ Inspect `v8project.yaml` fields that affect the failing command: Missing 1C platform, EDT CLI, IBCMD, or test runner utilities are environment/setup issues. Report the missing utility and the config fields used for discovery. +`tools download` errors that mention the maximum download size mean the selected GitHub release asset or source archive exceeded the 512 MiB response-body limit. Do not retry with `--force`; pick a smaller artifact or install the tool manually and point `v8project.local.yaml` to that local path. + Stale incremental state after branch switches, rebases, or large source moves usually calls for: ```bash diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 39dad70..24befc3 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -121,6 +121,10 @@ v8-runner tools download client-mcp [--sources] [--force] `vanessa` заполняет `tools.va.epf_path`, `client-mcp` заполняет `tools.client_mcp.extension`; повторный запуск переиспользует уже скачанные файлы, а `--force` перезаписывает только managed targets, созданные `tools download`. +- Managed target определяется sidecar marker-файлом `tools download`; если публикация файла или + каталога не завершилась, новый marker очищается и target не считается управляемым. +- Каждый HTTP response body ограничен 512 MiB; превышение лимита возвращает ошибку до публикации + target. ### `extensions` diff --git a/spec/architecture/arc42/06-runtime-view.md b/spec/architecture/arc42/06-runtime-view.md index f67eca6..2884466 100644 --- a/spec/architecture/arc42/06-runtime-view.md +++ b/spec/architecture/arc42/06-runtime-view.md @@ -88,6 +88,11 @@ sequenceDiagram `vanessa` и `tools.client_mcp.extension` для `client-mcp`; загрузка не устанавливает расширения в ИБ, не подменяет `build` и при `--force` заменяет только managed targets, созданные этой командой. +- Managed target фиксируется sidecar marker-файлом до publish phase. Если публикация скачанного + файла или каталога завершается ошибкой, новый marker очищается, чтобы следующий запуск не считал + неуспешный target управляемым. +- HTTP download path ограничивает response body 512 MiB и прерывает сценарий до распаковки или + публикации, если release asset или source archive превышает лимит. ### 6.5 Сценарий MCP EDT Syntax diff --git a/src/platform/download.rs b/src/platform/download.rs index e97f91e..b2ff223 100644 --- a/src/platform/download.rs +++ b/src/platform/download.rs @@ -10,6 +10,7 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); const RETRY_ATTEMPTS: usize = 3; const RETRY_DELAY: Duration = Duration::from_secs(2); const READ_BUFFER_SIZE: usize = 64 * 1024; +const MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024; #[derive(Debug, Error)] pub enum DownloadError { @@ -25,6 +26,15 @@ pub enum DownloadError { #[error("HTTP response read failed for {url}: {source}")] Read { url: String, source: std::io::Error }, + #[error( + "HTTP response for {url} exceeds maximum download size {max_bytes} bytes: {size_bytes} bytes" + )] + ResponseTooLarge { + url: String, + size_bytes: u64, + max_bytes: u64, + }, + #[error("HTTP download timed out after {timeout_ms}ms")] TimedOut { timeout_ms: u64 }, @@ -112,8 +122,11 @@ fn download_once( }); } - let capacity = response - .content_length() + let content_length = response.content_length(); + if let Some(size_bytes) = content_length { + ensure_allowed_download_size(&url_text, size_bytes)?; + } + let capacity = content_length .and_then(|value| usize::try_from(value).ok()) .unwrap_or_default(); let mut bytes = Vec::with_capacity(capacity); @@ -131,10 +144,28 @@ fn download_once( if read == 0 { return Ok(bytes); } + let next_len = bytes + .len() + .checked_add(read) + .and_then(|value| u64::try_from(value).ok()) + .unwrap_or(u64::MAX); + ensure_allowed_download_size(&url_text, next_len)?; bytes.extend_from_slice(&buffer[..read]); } } +fn ensure_allowed_download_size(url: &str, size_bytes: u64) -> Result<(), DownloadError> { + if size_bytes > MAX_DOWNLOAD_BYTES { + Err(DownloadError::ResponseTooLarge { + url: url.to_owned(), + size_bytes, + max_bytes: MAX_DOWNLOAD_BYTES, + }) + } else { + Ok(()) + } +} + fn remaining_budget( timeout: Option, started: Instant, @@ -184,9 +215,37 @@ impl DownloadError { DownloadError::Read { .. } => true, DownloadError::Status { status, .. } => status.is_server_error(), DownloadError::Client(_) + | DownloadError::ResponseTooLarge { .. } | DownloadError::TimedOut { .. } | DownloadError::Cancelled | DownloadError::InvalidUtf8(_) => false, } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn download_size_limit_accepts_boundary_size() { + ensure_allowed_download_size("https://example.invalid/file", MAX_DOWNLOAD_BYTES) + .expect("boundary size is accepted"); + } + + #[test] + fn download_size_limit_rejects_oversized_response() { + let error = + ensure_allowed_download_size("https://example.invalid/file", MAX_DOWNLOAD_BYTES + 1) + .expect_err("oversized response is rejected"); + + assert!(matches!( + error, + DownloadError::ResponseTooLarge { + size_bytes, + max_bytes, + .. + } if size_bytes == MAX_DOWNLOAD_BYTES + 1 && max_bytes == MAX_DOWNLOAD_BYTES + )); + } +} diff --git a/src/use_cases/tools_download.rs b/src/use_cases/tools_download.rs index ceccc99..1135e8f 100644 --- a/src/use_cases/tools_download.rs +++ b/src/use_cases/tools_download.rs @@ -15,7 +15,9 @@ use crate::domain::tools_download::{ }; use crate::platform::download; use crate::support::error::AppError; -use crate::support::fs::{ensure_dir, publish_file_atomically, replace_dir_atomically}; +use crate::support::fs::{ + ensure_dir, publish_file_atomically, remove_path_if_exists, replace_dir_atomically, +}; use crate::use_cases::context::ExecutionContext; use crate::use_cases::request::ToolsDownloadRequest; use crate::use_cases::result::{UseCaseFailure, UseCaseResult}; @@ -263,8 +265,7 @@ fn download_asset_file( asset.name )) })?; - write_file_download_marker(target_path)?; - publish_bytes(context, &bytes, target_path) + publish_file_bytes_with_marker(context, &bytes, target_path) } fn download_single_file_from_zip( @@ -297,8 +298,7 @@ fn download_single_file_from_zip( )) })?; let file = find_file_in_zip(&bytes, file_name)?; - write_file_download_marker(target_path)?; - publish_bytes(context, &file, target_path) + publish_file_bytes_with_marker(context, &file, target_path) } fn download_source_subdir( @@ -340,6 +340,8 @@ fn download_source_subdir( let _ = fs::remove_dir_all(&staged); })?; + let marker_existed = marker_path.exists(); + write_source_download_marker(target_path, marker_path)?; let publish_phase = context.run_no_process_critical_phase(|| { replace_dir_atomically( &staged, @@ -353,9 +355,12 @@ fn download_source_subdir( ) }); match publish_phase { - Ok(_) => write_source_download_marker(target_path, marker_path), + Ok(_) => Ok(()), Err(error) => { let _ = fs::remove_dir_all(&staged); + if !marker_existed { + let _ = remove_path_if_exists(marker_path); + } Err(AppError::Runtime(format!( "failed to publish source directory '{}': {error}", target_path.display() @@ -478,6 +483,25 @@ fn publish_bytes( } } +fn publish_file_bytes_with_marker( + context: &ExecutionContext, + bytes: &[u8], + target_path: &Path, +) -> Result<(), AppError> { + let marker_path = file_download_marker_path(target_path); + let marker_existed = marker_path.exists(); + write_file_download_marker(target_path)?; + match publish_bytes(context, bytes, target_path) { + Ok(()) => Ok(()), + Err(error) => { + if !marker_existed { + let _ = remove_path_if_exists(&marker_path); + } + Err(error) + } + } +} + fn should_download_file(path: &Path, force: bool) -> Result { if path.exists() && path.is_dir() { return Err(AppError::Validation(format!( From 43674ee28a9f68acc70dfa2293288186d7130e75 Mon Sep 17 00:00:00 2001 From: alkoleft Date: Tue, 12 May 2026 02:29:08 +0300 Subject: [PATCH 10/16] chore(release): bump version to 0.5.0 - update package and lockfile version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81d17e1..39f31ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2350,7 +2350,7 @@ dependencies = [ [[package]] name = "v8-runner" -version = "0.4.2" +version = "0.5.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 08c9f3f..9698c74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "v8-runner" -version = "0.4.2" +version = "0.5.0" edition = "2021" [[bin]] From b465d01966a89af80417a6dbe511de1df5f49513 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Tue, 12 May 2026 21:12:03 +0000 Subject: [PATCH 11/16] feat(build): add --dynamic flag and infobase.unlock_code config - CLI flag `--dynamic` for `build` enables /UpdateDBCfg -Dynamic+ - New config field `build.dynamicUpdate` (default false), CLI overrides config - New config field `infobase.unlock_code` propagates /UC to DESIGNER - Mask unlock_code in command-render logs like password (/UC ***) - Propagate dynamic flag through BuildRequest, McpBuildProjectRequest and the execute_source_set_step pipeline; `load` and tool-extension flows keep the historical static update - Bump version 0.4.2 -> 0.5.0 - Regenerate JSON schemas for v8project.yaml / v8project.local.yaml --- SKILL/references/config-and-backends.md | 2 + SKILL/references/project-workflows.md | 12 ++ docs/CAPABILITIES.md | 8 +- docs/CONFIGURATION.md | 24 +++ src/cli/args.rs | 8 + src/cli/execute.rs | 6 + src/config/loader.rs | 31 ++++ src/config/model.rs | 22 +++ src/config/schema.rs | 14 ++ src/config/validate.rs | 2 + src/mcp/port.rs | 1 + src/mcp/request.rs | 5 + src/mcp/service.rs | 2 + src/platform/connection.rs | 52 ++++++ src/platform/designer.rs | 89 ++++++++++- src/platform/process.rs | 26 +++ src/use_cases/build_project.rs | 177 ++++++++++++++++++++- src/use_cases/build_project/coordinator.rs | 2 + src/use_cases/load_artifact.rs | 4 +- src/use_cases/request.rs | 5 + src/use_cases/run_tests/coordinator.rs | 3 + src/use_cases/tool_extension.rs | 7 +- 22 files changed, 495 insertions(+), 7 deletions(-) diff --git a/SKILL/references/config-and-backends.md b/SKILL/references/config-and-backends.md index f9d67c9..cc0ea10 100644 --- a/SKILL/references/config-and-backends.md +++ b/SKILL/references/config-and-backends.md @@ -10,6 +10,8 @@ settings before CLI overrides. - `format`: `DESIGNER` or `EDT`. - `builder`: `DESIGNER` or `IBCMD`. - `infobase.connection`: often `File=build/ib` for local automation. +- `infobase.unlock_code`: optional infobase locking code, propagated to DESIGNER as `/UC `. Required when the configuration was sealed with "Установить пароль"; masked in logs. Place this in `v8project.local.yaml` together with `infobase.password`. +- `build.dynamicUpdate`: project-wide default for `/UpdateDBCfg -Dynamic+`. Off by default. CLI `build --dynamic` overrides it for a single invocation. - `source-set`: ordered configuration and extension sources. - `tools.platform.path` or `tools.platform.version`: 1C platform discovery hints. - `tools.edt_cli.path`, `version`, and `interactive-mode`: EDT CLI discovery and execution mode. diff --git a/SKILL/references/project-workflows.md b/SKILL/references/project-workflows.md index 76cc98a..83bbe28 100644 --- a/SKILL/references/project-workflows.md +++ b/SKILL/references/project-workflows.md @@ -43,6 +43,18 @@ Use a full rebuild after branch switches, rebases, broad object moves, or suspic v8-runner build --full-rebuild ``` +Use dynamic update when the infobase has live HTTP services or background jobs that block the +exclusive lock required by the default static `/UpdateDBCfg`: + +```bash +v8-runner build --dynamic +``` + +Equivalent project-wide default is `build.dynamicUpdate: true` in `v8project.yaml`. The CLI +flag overrides the config for one invocation. The platform refuses dynamic mode when the +change set requires restructuring; `v8-runner` surfaces that error verbatim and does not fall +back to a static update. + `build` is a common workflow. For EDT projects it may export EDT sources to Designer files before applying them through the configured backend. For Designer projects it applies Designer sources directly through the configured backend. If `tools.client_mcp.extension` is configured, `build` also prepares that tool extension after the project source-set stage, including scoped `--source-set` builds. Source-backed tool extensions use their own change-detection state and are skipped when unchanged; use `build --full-rebuild` to force refresh. Do not add a tool extension as a project `source-set` or select it with `--source-set`. diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 24befc3..dd9ddb8 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -139,10 +139,14 @@ v8-runner extensions [--name ...] ### `build` ```bash -v8-runner build [--source-set ] [--full-rebuild] +v8-runner build [--source-set ] [--full-rebuild] [--dynamic] ``` - Без `--source-set` обрабатывает все configured `source-set` в canonical order. +- `--dynamic` (или `build.dynamicUpdate: true` в `v8project.yaml`) добавляет к + `/UpdateDBCfg` флаг `-Dynamic+`. Платформа применяет изменения без захвата + исключительной блокировки; на изменениях, требующих реструктуризации, DESIGNER возвращает + ошибку — fallback на статический режим не выполняется. - С `--source-set` project stage анализирует и строит только указанный `source-set`; неизвестное имя отклоняется как validation error. - Для `DESIGNER` выбирает incremental, partial или full path по изменённым файлам выбранного scope. @@ -308,7 +312,7 @@ v8-runner mcp serve http | Инструмент | Основные поля запроса | Примечания | | --- | --- | --- | -| `build_project` | `fullRebuild`, `sourceSet` | `fullRebuild=false`; `sourceSet` omitted значит все source-set | +| `build_project` | `fullRebuild`, `sourceSet`, `dynamicUpdate` | `fullRebuild=false`; `sourceSet` omitted значит все source-set; `dynamicUpdate` (опц.) переопределяет `build.dynamicUpdate` для одного вызова | | `run_all_tests` | `full` | Компактный вывод по умолчанию | | `run_module_tests` | `moduleName`, `full` | Отклоняет пустой `moduleName` | | `dump_config` | `mode`, `extension`, `objects` | Пустой `mode` нормализуется в `INCREMENTAL` | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5888148..614a5e7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -127,6 +127,7 @@ infobase: connection: "File=build/ib" user: Admin password: secret + unlock_code: seal-42 # optional `/UC <значение>` для запароленных конфигураций source-set: - name: main @@ -138,6 +139,7 @@ source-set: build: partialLoadThreshold: 20 + dynamicUpdate: false # `/UpdateDBCfg -Dynamic+` по умолчанию tools: client_mcp: @@ -314,6 +316,16 @@ tests: Credentials самой информационной базы. +#### `infobase.unlock_code` + +- Тип: строка +- Обязателен: нет + +Кодовое слово (`Конфигурация → Установить пароль`), которое транслируется в DESIGNER как +`/UC <значение>`. Без него платформа отказывается выполнять административные операции на +запароленных конфигурациях. Значение маскируется в логах команд (`/UC ***`), поэтому его +безопасно держать в `v8project.local.yaml` рядом с `infobase.password`. + #### `infobase.dbms` - Тип: объект @@ -373,6 +385,18 @@ Validation rules: Порог между partial и full load. +#### `build.dynamicUpdate` + +- Тип: boolean +- По умолчанию: `false` + +Включает режим динамического обновления (`/UpdateDBCfg -Dynamic+`) для `build`. Полезно, +когда в инфобазе живут HTTP-сервисы или фоновые задания и захват исключительной блокировки +нежелателен. Если изменения требуют реструктуризации, DESIGNER возвращает ошибку, и +`v8-runner` пробрасывает её наружу — fallback на статический режим не выполняется. + +CLI-флаг `v8-runner build --dynamic` переопределяет это значение на одну команду. + CLI selector `v8-runner build --source-set ` использует `source-set[].name` как stable runtime identity и не добавляет отдельное поле конфигурации. Если selector не задан, `build` обрабатывает все `source-set`. diff --git a/src/cli/args.rs b/src/cli/args.rs index 3cb78a0..b595e6e 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -169,6 +169,14 @@ pub struct BuildArgs { /// Limit build to one source-set from v8project.yaml #[arg(long)] pub source_set: Option, + + /// Apply changes via `/UpdateDBCfg -Dynamic+` (no exclusive lock). + /// + /// Overrides `build.dynamicUpdate` from v8project.yaml for this run. The platform itself + /// refuses dynamic mode when restructuring is required; the runner surfaces that error + /// instead of falling back to a static update. + #[arg(long)] + pub dynamic: bool, } #[derive(Args, Debug)] diff --git a/src/cli/execute.rs b/src/cli/execute.rs index dd6b05f..222dd23 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -807,6 +807,8 @@ fn map_build_request(args: &BuildArgs) -> BuildRequest { BuildRequest { full_rebuild: args.full_rebuild, source_set: args.source_set.clone(), + // CLI flag is a one-shot override; absence means "fall back to project config". + dynamic_update: if args.dynamic { Some(true) } else { None }, } } @@ -2548,6 +2550,7 @@ mod tests { map_build_request(&BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }) .full_rebuild ); @@ -2829,6 +2832,7 @@ mod tests { command_name(&Command::Build(BuildArgs { full_rebuild: false, source_set: None, + dynamic: false, })), CommandName::Build ); @@ -2899,6 +2903,7 @@ mod tests { &Command::Build(BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }), None, &presenter, @@ -3027,6 +3032,7 @@ mod tests { &Command::Build(BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }), None, &presenter, diff --git a/src/config/loader.rs b/src/config/loader.rs index 6c61089..8416c4a 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -715,6 +715,37 @@ mod tests { assert_eq!(config.build.partial_load_threshold, 7); } + #[test] + fn load_config_reads_build_dynamic_update_and_infobase_unlock_code() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let src = base.join("src"); + std::fs::create_dir_all(&src).expect("src dir"); + let config_path = dir.path().join("v8project.yaml"); + std::fs::write( + &config_path, + format!( + "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\n unlock_code: seal-1\nbuild:\n dynamicUpdate: true\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", + base.display(), + work.display() + ), + ) + .expect("write config"); + + let config = load_config(config_path.to_str(), None).expect("load config"); + + assert!(config.build.dynamic_update); + assert_eq!(config.infobase.unlock_code.as_deref(), Some("seal-1")); + + // And the resulting V8Connection carries the unlock code into the platform layer. + let connection = config.v8_connection(); + assert_eq!(connection.unlock_code.as_deref(), Some("seal-1")); + let args = connection.args(); + let uc_index = args.iter().position(|arg| arg == "/UC").expect("/UC"); + assert_eq!(args.get(uc_index + 1).map(String::as_str), Some("seal-1")); + } + #[test] fn load_config_reads_test_timeout_from_exact_yaml_key() { let dir = tempdir().expect("tempdir"); diff --git a/src/config/model.rs b/src/config/model.rs index c986256..a53e6ef 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -66,6 +66,14 @@ pub struct InfobaseConfig { /// Optional infobase password passed to platform utilities. pub password: Option, + /// Optional infobase unlock code propagated to DESIGNER calls as `/UC `. + /// + /// Required by configurations sealed with `Конфигурация → Установить пароль`; without the + /// matching code the platform refuses every administrative operation. The value is treated + /// as a secret and masked in command logs. + #[serde(default)] + pub unlock_code: Option, + /// Optional DBMS contract for server-based infobases. #[serde(default)] pub dbms: Option, @@ -79,6 +87,7 @@ impl InfobaseConfig { connection: connection.into(), user: None, password: None, + unlock_code: None, dbms: None, } } @@ -98,6 +107,7 @@ impl InfobaseConfig { connection: connection.into(), user: None, password: None, + unlock_code: None, dbms: Some(dbms), } } @@ -159,6 +169,7 @@ impl AppConfig { let mut conn = V8Connection::from_connection_string(&self.infobase.connection); conn.user = self.infobase.user.clone(); conn.password = self.infobase.password.clone(); + conn.unlock_code = self.infobase.unlock_code.clone(); conn } @@ -226,12 +237,23 @@ impl SourceSetPurpose { pub struct BuildConfig { #[serde(default = "default_partial_load_threshold")] pub partial_load_threshold: usize, + + /// Default mode for `/UpdateDBCfg` during `build`. + /// + /// When `true`, DESIGNER is invoked with `-Dynamic+`, which lets the platform apply + /// metadata changes without taking an exclusive infobase lock (useful when HTTP services + /// or background jobs are live). If the change set is incompatible with dynamic update + /// (e.g. restructuring), DESIGNER returns an error — the runner does NOT silently fall + /// back to a static update. CLI `--dynamic` overrides this field for a single invocation. + #[serde(default)] + pub dynamic_update: bool, } impl Default for BuildConfig { fn default() -> Self { Self { partial_load_threshold: default_partial_load_threshold(), + dynamic_update: false, } } } diff --git a/src/config/schema.rs b/src/config/schema.rs index 4796d4b..bc8488a 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -441,6 +441,9 @@ struct InfobaseSchema { /// Optional infobase password passed to platform utilities. #[serde(default, skip_serializing_if = "Option::is_none")] password: Option, + /// Optional unlock code propagated as `/UC ` to DESIGNER. Masked in command logs. + #[serde(default, skip_serializing_if = "Option::is_none")] + unlock_code: Option, /// Optional DBMS settings for server-based infobases. #[serde(default, skip_serializing_if = "Option::is_none")] dbms: Option, @@ -463,6 +466,9 @@ struct PartialInfobaseSchema { /// Optional local infobase password. #[serde(default, skip_serializing_if = "Option::is_none")] password: Option, + /// Optional local infobase unlock code propagated as `/UC `. Masked in command logs. + #[serde(default, skip_serializing_if = "Option::is_none")] + unlock_code: Option, /// Optional local DBMS settings override. #[serde(default, skip_serializing_if = "Option::is_none")] dbms: Option, @@ -522,6 +528,14 @@ struct BuildSchema { )] #[schemars(with = "usize")] partial_load_threshold: Option, + /// Default `/UpdateDBCfg -Dynamic+` toggle for `build`. CLI `--dynamic` overrides this. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "bool")] + dynamic_update: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] diff --git a/src/config/validate.rs b/src/config/validate.rs index cd8e0e4..7385fa9 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -1335,6 +1335,7 @@ mod tests { }], build: BuildConfig { partial_load_threshold: 0, + dynamic_update: false, }, tools: ToolsConfig::default(), mcp: Default::default(), @@ -2150,6 +2151,7 @@ mod tests { connection: "Srvr=localhost;Ref=ib".to_owned(), user: None, password: None, + unlock_code: None, dbms: None, }, source_sets: vec![SourceSetConfig { diff --git a/src/mcp/port.rs b/src/mcp/port.rs index 2e8db54..54cf0bc 100644 --- a/src/mcp/port.rs +++ b/src/mcp/port.rs @@ -231,6 +231,7 @@ mod tests { &BuildRequest { full_rebuild: true, source_set: None, + dynamic_update: None, }, ) .expect_err("busy workspace"); diff --git a/src/mcp/request.rs b/src/mcp/request.rs index 05130d7..8948876 100644 --- a/src/mcp/request.rs +++ b/src/mcp/request.rs @@ -11,6 +11,11 @@ pub struct McpBuildProjectRequest { /// Optional source-set selector from v8project.yaml. #[schemars(description = "Source-set name to build. When omitted, all source-sets are built.")] pub source_set: Option, + /// Optional one-shot override for `/UpdateDBCfg -Dynamic+`. + #[schemars( + description = "Override build.dynamicUpdate for this call: true applies changes without exclusive lock." + )] + pub dynamic_update: Option, } /// MCP request for `run_all_tests`. diff --git a/src/mcp/service.rs b/src/mcp/service.rs index 4a7d003..0eaba04 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -59,6 +59,7 @@ where let use_case_request = BuildRequest { full_rebuild: request.full_rebuild.unwrap_or(false), source_set: request.source_set.clone(), + dynamic_update: request.dynamic_update, }; match self @@ -823,6 +824,7 @@ mod tests { &McpBuildProjectRequest { full_rebuild: Some(true), source_set: Some("main".to_owned()), + dynamic_update: None, }, ) .expect("success"); diff --git a/src/platform/connection.rs b/src/platform/connection.rs index c4880fa..10a7591 100644 --- a/src/platform/connection.rs +++ b/src/platform/connection.rs @@ -7,6 +7,12 @@ pub struct V8Connection { pub user: Option, /// Optional password added as `/P `. pub password: Option, + /// Optional infobase unlock code emitted as `/UC `. + /// + /// Configurations protected by a locking code (`Конфигурация → Установить пароль`) refuse any + /// administrative DESIGNER operation until the matching `/UC` is supplied; an empty string is + /// treated as "no unlock code" for backwards compatibility with explicit nulling in overlays. + pub unlock_code: Option, } impl V8Connection { @@ -24,6 +30,7 @@ impl V8Connection { connection_args, user: None, password: None, + unlock_code: None, } } @@ -40,6 +47,14 @@ impl V8Connection { args.push(password.clone()); } } + if let Some(unlock_code) = &self.unlock_code { + // Empty value is intentionally treated as absent: a misconfigured overlay setting + // `unlock_code: ""` should not push an `/UC` token without a value to the platform. + if !unlock_code.is_empty() { + args.push("/UC".to_owned()); + args.push(unlock_code.clone()); + } + } args } @@ -156,4 +171,41 @@ mod tests { assert_eq!(connection.args(), vec!["/F", "/tmp/ib"]); assert_eq!(connection.file_path(), Some("/tmp/ib")); } + + #[test] + fn appends_unlock_code_after_credentials() { + let mut connection = V8Connection::from_connection_string("File=/tmp/ib"); + connection.user = Some("Admin".to_owned()); + connection.password = Some("pw".to_owned()); + connection.unlock_code = Some("uc-secret".to_owned()); + + assert_eq!( + connection.args(), + vec![ + "/IBConnectionString", + "File=/tmp/ib", + "/N", + "Admin", + "/P", + "pw", + "/UC", + "uc-secret", + ] + ); + } + + #[test] + fn omits_unlock_code_when_value_is_empty() { + let mut connection = V8Connection::from_connection_string("File=/tmp/ib"); + connection.unlock_code = Some(String::new()); + + assert!(!connection.args().iter().any(|arg| arg == "/UC")); + } + + #[test] + fn omits_unlock_code_when_not_configured() { + let connection = V8Connection::from_connection_string("File=/tmp/ib"); + + assert!(!connection.args().iter().any(|arg| arg == "/UC")); + } } diff --git a/src/platform/designer.rs b/src/platform/designer.rs index c98faa1..67be1f9 100644 --- a/src/platform/designer.rs +++ b/src/platform/designer.rs @@ -88,13 +88,22 @@ impl<'a> DesignerDsl<'a> { self.run(&args) } - /// `/UpdateDBCfg` + /// `/UpdateDBCfg [-Dynamic+] [-Extension ]` + /// + /// `dynamic = true` adds `-Dynamic+`, instructing the platform to apply the change set + /// without grabbing an exclusive infobase lock. The platform itself refuses dynamic mode + /// when restructuring is required; the runner surfaces that error verbatim instead of + /// retrying statically. pub fn update_db_cfg( &self, extension: Option<&str>, + dynamic: bool, ) -> Result { let mut args = self.base_args(); args.push("/UpdateDBCfg".to_owned()); + if dynamic { + args.push("-Dynamic+".to_owned()); + } if let Some(extension) = extension { args.push("-Extension".to_owned()); args.push(extension.to_owned()); @@ -439,6 +448,84 @@ mod tests { .contains("failed to read designer /Out log")); } + #[cfg(unix)] + #[test] + fn update_db_cfg_emits_dynamic_flag_when_requested() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("1cv8"); + let args_log = dir.path().join("args.log"); + write_script( + &script, + &format!("printf '%s\n' \"$@\" > \"{}\"\nexit 0", args_log.display()), + ); + let runner = ProcessExecutor; + let dsl = DesignerDsl::new( + script, + V8Connection::from_connection_string("File=/tmp/ib"), + &runner as &dyn ProcessRunner, + None, + ); + + dsl.update_db_cfg(None, true).expect("dynamic update"); + + let args = fs::read_to_string(&args_log).expect("args log"); + assert!(args.contains("/UpdateDBCfg")); + assert!(args.contains("-Dynamic+")); + } + + #[cfg(unix)] + #[test] + fn update_db_cfg_omits_dynamic_flag_by_default() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("1cv8"); + let args_log = dir.path().join("args.log"); + write_script( + &script, + &format!("printf '%s\n' \"$@\" > \"{}\"\nexit 0", args_log.display()), + ); + let runner = ProcessExecutor; + let dsl = DesignerDsl::new( + script, + V8Connection::from_connection_string("File=/tmp/ib"), + &runner as &dyn ProcessRunner, + None, + ); + + dsl.update_db_cfg(Some("Ext"), false) + .expect("static update"); + + let args = fs::read_to_string(&args_log).expect("args log"); + assert!(args.contains("/UpdateDBCfg")); + assert!(!args.contains("-Dynamic")); + assert!(args.contains("-Extension")); + } + + #[cfg(unix)] + #[test] + fn base_args_propagate_unlock_code_to_every_designer_command() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("1cv8"); + let args_log = dir.path().join("args.log"); + write_script( + &script, + &format!("printf '%s\n' \"$@\" > \"{}\"\nexit 0", args_log.display()), + ); + let runner = ProcessExecutor; + let mut connection = V8Connection::from_connection_string("File=/tmp/ib"); + connection.unlock_code = Some("seal".to_owned()); + let dsl = DesignerDsl::new(script, connection, &runner as &dyn ProcessRunner, None); + + dsl.update_db_cfg(None, false).expect("update"); + + let args = fs::read_to_string(&args_log).expect("args log"); + let lines: Vec<&str> = args.lines().collect(); + let uc_index = lines + .iter() + .position(|line| *line == "/UC") + .expect("/UC token"); + assert_eq!(lines.get(uc_index + 1).copied(), Some("seal")); + } + #[cfg(unix)] #[test] fn dump_config_to_files_requests_config_dump_info_update() { diff --git a/src/platform/process.rs b/src/platform/process.rs index f86f9f3..e85fdf5 100644 --- a/src/platform/process.rs +++ b/src/platform/process.rs @@ -615,6 +615,9 @@ fn is_sensitive_flag(arg: &str) -> bool { "--db-pwd", "--target-database-password", "--target-db-pwd", + // `/UC` carries the infobase unlock code (TASK-124) — treat it as a secret. + "/UC", + "-UC", ]; FLAGS.iter().any(|flag| arg.eq_ignore_ascii_case(flag)) @@ -629,6 +632,9 @@ fn split_sensitive_assignment(arg: &str) -> Option<(&str, &str)> { "--db-pwd", "--target-database-password", "--target-db-pwd", + // `/UC` carries the infobase unlock code (TASK-124) — treat it as a secret. + "/UC", + "-UC", ]; let (key, value) = arg.split_once('=')?; @@ -707,6 +713,26 @@ mod tests { ); } + #[test] + fn render_command_masks_unlock_code_flag() { + let rendered = render_command(&ProcessRequest { + program: Path::new("/tmp/1cv8").to_path_buf(), + args: vec![ + "DESIGNER".to_owned(), + "/UC".to_owned(), + "seal-42".to_owned(), + "/UpdateDBCfg".to_owned(), + ], + workdir: None, + stdout_log_path: None, + stderr_log_path: None, + startup_probe: None, + }); + + assert!(rendered.contains("/UC ***")); + assert!(!rendered.contains("seal-42")); + } + #[test] fn render_command_masks_ibcmd_password_flags() { let rendered = render_command(&ProcessRequest { diff --git a/src/use_cases/build_project.rs b/src/use_cases/build_project.rs index 5c2b359..c0092bb 100644 --- a/src/use_cases/build_project.rs +++ b/src/use_cases/build_project.rs @@ -122,6 +122,14 @@ fn run_build_ibcmd( Ok(result) } +/// Resolves the effective `/UpdateDBCfg -Dynamic+` flag for a build invocation. +/// +/// `args.dynamic_update` (CLI/MCP one-shot override) takes priority over +/// `config.build.dynamic_update` (project-wide default). Default is `false`. +pub(crate) fn resolve_dynamic_update(config: &AppConfig, args: &BuildArgs) -> bool { + args.dynamic_update.unwrap_or(config.build.dynamic_update) +} + fn validate_designer_supported_matrix(config: &AppConfig) -> Option { if config.format == SourceFormat::Designer && matches!( @@ -416,6 +424,11 @@ fn recreate_directory(path: &Path) -> std::io::Result<()> { std::fs::create_dir_all(path) } +// Designer build step is intentionally wide: it threads the execution context, config, the +// source set and its commit/load directories, the partial-load file list and the dynamic +// flag. Refactoring into a builder is out of scope for TASK-124; the IBCMD sibling already +// breaches the same limit (9/7) without an allow. +#[allow(clippy::too_many_arguments)] fn execute_source_set_step( context: &ExecutionContext, config: &AppConfig, @@ -427,6 +440,7 @@ fn execute_source_set_step( step_index: usize, partial_paths: Option<&[PathBuf]>, commit: &StepCommit, + dynamic_update_db_cfg: bool, ) -> Result, AppError> { if let Some(error) = interruption_before_safe_point( context, @@ -517,7 +531,7 @@ fn execute_source_set_step( "update", InterruptionSafetyClass::CriticalNonAbortable, )? - .update_db_cfg(extension_name(source_set)) + .update_db_cfg(extension_name(source_set), dynamic_update_db_cfg) .map_err(AppError::from)?; ensure_platform_success("update_db_cfg", source_set, &update_result)?; @@ -851,6 +865,7 @@ mod tests { ], build: BuildConfig { partial_load_threshold: threshold, + dynamic_update: false, }, tools: ToolsConfig { platform: PlatformToolConfig { @@ -891,6 +906,7 @@ mod tests { ], build: BuildConfig { partial_load_threshold: 20, + dynamic_update: false, }, tools: ToolsConfig { platform: PlatformToolConfig { @@ -914,6 +930,15 @@ mod tests { BuildArgs { full_rebuild, source_set: None, + dynamic_update: None, + } + } + + fn build_args_dynamic(full_rebuild: bool, dynamic: bool) -> BuildArgs { + BuildArgs { + full_rebuild, + source_set: None, + dynamic_update: Some(dynamic), } } @@ -2582,6 +2607,153 @@ mod tests { assert!(matches!(rerun.steps[0].mode, BuildMode::Skipped)); } + #[cfg(unix)] + #[test] + fn dynamic_cli_flag_emits_dynamic_marker_in_update_db_cfg() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + // CLI `--dynamic` should reach DESIGNER as `/UpdateDBCfg -Dynamic+`. + let result = run_build(&config, &build_args_dynamic(false, true)).expect("dynamic build"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + assert!(result.ok); + assert!(calls_text.contains("/UpdateDBCfg")); + assert!(calls_text.contains("-Dynamic+")); + } + + #[cfg(unix)] + #[test] + fn build_dynamic_update_config_default_emits_dynamic_marker() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let mut config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + config.build.dynamic_update = true; + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + // `build.dynamicUpdate: true` in v8project.yaml is enough — no CLI flag needed. + let result = run_build(&config, &build_args(false)).expect("dynamic build"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + assert!(result.ok); + assert!(calls_text.contains("-Dynamic+")); + } + + #[cfg(unix)] + #[test] + fn build_without_dynamic_flag_emits_static_update_db_cfg() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + // Regression guard: default behavior MUST NOT add `-Dynamic+`. + let _ = run_build(&config, &build_args(false)).expect("static build"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + assert!(calls_text.contains("/UpdateDBCfg")); + assert!(!calls_text.contains("-Dynamic")); + } + + #[cfg(unix)] + #[test] + fn infobase_unlock_code_propagates_to_designer_args() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let mut config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + config.infobase.unlock_code = Some("seal-42".to_owned()); + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + let _ = run_build(&config, &build_args(false)).expect("build with /UC"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + // `/UC` reaches DESIGNER as a separate token + value pair. + assert!(calls_text.contains("/UC")); + assert!(calls_text.contains("seal-42")); + } + #[cfg(unix)] #[test] fn changed_extension_only_loads_extension_and_preserves_other_storage() { @@ -2656,6 +2828,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("ext".to_owned()), + dynamic_update: None, }, ) .expect("build"); @@ -2707,6 +2880,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("ext".to_owned()), + dynamic_update: None, }, ) .expect("build"); @@ -2745,6 +2919,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("missing".to_owned()), + dynamic_update: None, }, ) .expect_err("unknown source-set must fail"); diff --git a/src/use_cases/build_project/coordinator.rs b/src/use_cases/build_project/coordinator.rs index 4f06468..5d72410 100644 --- a/src/use_cases/build_project/coordinator.rs +++ b/src/use_cases/build_project/coordinator.rs @@ -174,6 +174,7 @@ pub(super) fn run_build_designer( index, partial_paths.as_deref(), &commit, + resolve_dynamic_update(config, args), ) { Ok(warnings) => push_build_step( &mut steps, @@ -888,6 +889,7 @@ pub(super) fn run_build_edt( index, partial_paths.as_deref(), &commit, + resolve_dynamic_update(config, args), ) } BuilderBackend::Ibcmd => { diff --git a/src/use_cases/load_artifact.rs b/src/use_cases/load_artifact.rs index 54acc42..82dd4c2 100644 --- a/src/use_cases/load_artifact.rs +++ b/src/use_cases/load_artifact.rs @@ -328,8 +328,10 @@ fn run_load( "load: update_db_cfg", "[Конфигуратор] updating database configuration", ); + // `load` mirrors the historical static update — DBA approves the artifact, the runner + // applies the locked change. Dynamic mode is scoped to `build` per TASK-124. let update_result = update_dsl - .update_db_cfg(resolved.extension.as_deref()) + .update_db_cfg(resolved.extension.as_deref(), false) .map_err(AppError::from); let update_result = match update_result { diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index 2a8d812..a8a09d6 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -16,6 +16,11 @@ pub struct BuildRequest { pub full_rebuild: bool, /// Optional source-set selector. When absent, all configured source-sets are built. pub source_set: Option, + /// Per-invocation override for `/UpdateDBCfg -Dynamic+`. + /// + /// `None` means "no override — use `build.dynamic_update` from project config"; + /// `Some(true)` / `Some(false)` overrides the project default for this call. + pub dynamic_update: Option, } /// Transport-neutral request for the `tools download` use case. diff --git a/src/use_cases/run_tests/coordinator.rs b/src/use_cases/run_tests/coordinator.rs index 2a7bf44..9e29c12 100644 --- a/src/use_cases/run_tests/coordinator.rs +++ b/src/use_cases/run_tests/coordinator.rs @@ -76,6 +76,9 @@ pub(super) fn run_tests( &BuildArgs { full_rebuild: false, source_set: None, + // `test` always rebuilds with the project default (`build.dynamicUpdate`); the + // ergonomic CLI for one-shot dynamic remains `build --dynamic && test`. + dynamic_update: None, }, ) { Ok(result) => result, diff --git a/src/use_cases/tool_extension.rs b/src/use_cases/tool_extension.rs index 2b1229c..34003a5 100644 --- a/src/use_cases/tool_extension.rs +++ b/src/use_cases/tool_extension.rs @@ -318,8 +318,10 @@ fn prepare_designer_source_extension( "update", &extension_stage_detail("Конфигуратор", "Применение", extension), ); + // Tool-extension flow keeps the historical static update; dynamic mode is opt-in + // through `build --dynamic` / `build.dynamicUpdate` and not propagated here. let update = dsl - .update_db_cfg(Some(&extension.name)) + .update_db_cfg(Some(&extension.name), false) .map_err(AppError::from)?; ensure_tool_extension_success("update_db_cfg", extension, &update) } @@ -410,8 +412,9 @@ fn prepare_artifact_extension( extension, "update-artifact", )?; + // Tool-extension artifact apply path uses static update; see tool_extension::execute(). let update = dsl - .update_db_cfg(Some(&extension.name)) + .update_db_cfg(Some(&extension.name), false) .map_err(AppError::from)?; ensure_tool_extension_success("update_db_cfg", extension, &update) } From 68acc8ff12e93e1c6963fd7e7986451797c7ad4e Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Wed, 13 May 2026 12:10:51 +0000 Subject: [PATCH 12/16] fix(mcp): rename local transport mode to mcp - replace legacy transport config value with mcp\n- harden WS payload validation and schema boundaries\n- update docs, schemas, and launch tests --- README.md | 4 +- docs/CONFIGURATION.md | 16 +-- docs/schemas/v8project.local.schema.json | 7 +- docs/schemas/v8project.schema.json | 12 +- src/cli/args.rs | 8 +- src/cli/execute.rs | 20 ++-- src/config/model.rs | 6 +- src/config/schema.rs | 50 +++++++- src/config/validate.rs | 7 +- src/domain/launch.rs | 14 ++- src/use_cases/launch_app.rs | 31 +++-- src/use_cases/mcp_ws.rs | 137 +++++++++++++++------- src/use_cases/request.rs | 2 +- src/use_cases/run_tests/coordinator.rs | 20 +++- src/use_cases/run_tests/helpers.rs | 19 +-- tests/cli_launch.rs | 14 +-- v8-runner/SKILL.md | 2 +- v8-runner/references/bootstrap.md | 2 +- v8-runner/references/command-selection.md | 4 +- v8-runner/references/project-workflows.md | 14 +-- 20 files changed, 267 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index a0ea594..6363112 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,10 @@ v8-runner launch mcp va ### Подключение к session-manager (WS-режим) `launch mcp`, `launch mcp va`, `test yaxunit ...` и `test va ...` поддерживают подключение -1С-клиента к [`v8-client-session-manager`](../v8-client-session-manager/) вместо запуска +1С-клиента к [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager) вместо запуска локального HTTP MCP. По умолчанию — `auto`: TCP-probe адреса менеджера, при успехе собирается `/C"mcpMode=ws;manager_url=...;client_uid=...;kind=...;..."`, иначе используется -legacy `/C"runMcp;..."`. Полный список ключей `/C` и CLI-флагов см. в +MCP `/C"runMcp;..."`. Полный список ключей `/C` и CLI-флагов см. в [docs/CONFIGURATION.md](docs/CONFIGURATION.md#tools-client_mcp). ### Поднимите MCP transport (MCP-транспорт) для AI-агентов: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f708fb0..4ab7a49 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -141,7 +141,7 @@ tools: source: path: /path/to/onec-client-mcp/exts/client-mcp format: EDT - transport: auto # ws | legacy | auto (default) + transport: auto # ws | mcp | auto (default) manager_url: ws://127.0.0.1:4000/sessions log_level: info # off|error|warn|info|debug|trace ws_timeout_ms: 1000 @@ -446,11 +446,11 @@ runtime identity и не добавляет отдельное поле конф Поддержанные поля: -- `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit (legacy режим). +- `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit (MCP-режим). - `extension`, опциональное tool extension для клиентского MCP-сервера. -- `transport` (`ws`, `legacy`, `auto`; по умолчанию `auto`) — режим транспорта. См. раздел +- `transport` (`ws`, `mcp`, `auto`; по умолчанию `auto`) — режим транспорта. См. раздел «WS-режим к session-manager» ниже. -- `manager_url` — WS-эндпоинт `v8-client-session-manager`, +- `manager_url` — WS-эндпоинт `v8-client-session-manager` с IP-адресом и портом, по умолчанию `ws://127.0.0.1:4000/sessions`. - `log_level` (`off`/`error`/`warn`/`info`/`debug`/`trace`) — значение `mcp_log_level`, передаваемое в `/C` BSL-расширению `client_mcp`. @@ -458,7 +458,7 @@ runtime identity и не добавляет отдельное поле конф > 0). `launch mcp` передаёт `port` как `mcpPort` внутри `/C"runMcp..."` -если CLI не указал `--mcp-port` и выбран legacy/`auto`-fallback транспорт. +если CLI не указал `--mcp-port` и выбран `mcp`/`auto`-fallback транспорт. #### WS-режим к session-manager @@ -488,12 +488,12 @@ CLI-флаги: `--mcp-transport`, `--manager-url`, `--client-uid`, `--corr-id`, встроенными дефолтами. `client_uid` по умолчанию рандомный UUIDv4 на каждый запуск, `corr_id` по умолчанию `vr-<первые 8 символов uid>`. -Для `transport=auto` v8-runner делает короткий TCP-probe (200 ms) на хост:порт из -`manager_url`. При успехе выбирается WS, иначе legacy. Для `transport=ws` без живого +Для `transport=auto` v8-runner делает короткий TCP-probe (200 ms) на IP:порт из +`manager_url`. При успехе выбирается WS, иначе MCP. Для `transport=ws` без живого менеджера запуск падает с ошибкой `session-manager unreachable at `. Сам менеджер v8-runner не запускает — его нужно поднять отдельно -(см. соседний репозиторий [`v8-client-session-manager`](../../v8-client-session-manager/)). +(см. репозиторий [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager)). `extension` поддерживает: diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index 85a20c9..b322bbd 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -224,12 +224,17 @@ }, "transport": { "description": "Machine-local override of the default MCP client transport.", + "enum": [ + "ws", + "mcp", + "auto" + ], "type": "string" }, "ws_timeout_ms": { "description": "Machine-local override of the default `mcp_ws_timeout_ms`.", "format": "uint64", - "minimum": 0, + "minimum": 1, "type": "integer" } }, diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index 18811f7..dc0ba08 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -41,7 +41,7 @@ ] }, "manager_url": { - "description": "Default WS endpoint for the session-manager.", + "description": "Default WS endpoint with IP address and port for the session-manager.", "type": [ "string", "null" @@ -58,7 +58,13 @@ ] }, "transport": { - "description": "Default transport for the MCP client side: `ws`, `legacy` or `auto`.", + "description": "Default transport for the MCP client side: `ws`, `mcp` or `auto`.", + "enum": [ + "ws", + "mcp", + "auto", + null + ], "type": [ "string", "null" @@ -67,7 +73,7 @@ "ws_timeout_ms": { "description": "Default `mcp_ws_timeout_ms` value forwarded into the `/C` payload.", "format": "uint64", - "minimum": 0, + "minimum": 1, "type": [ "integer", "null" diff --git a/src/cli/args.rs b/src/cli/args.rs index 8c7dce3..0b0e9d8 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -325,9 +325,9 @@ pub struct LaunchArgs { #[derive(Args, Debug, Clone, Default, PartialEq, Eq)] #[command(next_help_heading = "MCP client WS options")] pub struct McpClientWsArgs { - /// Override the transport selection: `ws` forces WS, `legacy` forces local - /// HTTP MCP, `auto` probes the manager and falls back to legacy. - #[arg(long = "mcp-transport", value_parser = ["ws", "legacy", "auto"])] + /// Override the transport selection: `ws` forces WS, `mcp` forces local + /// HTTP MCP, `auto` probes the manager and falls back to MCP. + #[arg(long = "mcp-transport", value_parser = ["ws", "mcp", "auto"])] pub mcp_transport: Option, /// Override the session-manager WS endpoint @@ -345,7 +345,7 @@ pub struct McpClientWsArgs { pub corr_id: Option, /// Override the `mcp_log_level` value passed to the BSL devkit. - #[arg(long = "mcp-log-level", value_parser = ["off", "error", "warn", "info", "debug", "trace"])] + #[arg(long = "mcp-log-level")] pub mcp_log_level: Option, /// Override the `mcp_ws_timeout_ms` value passed to the BSL devkit diff --git a/src/cli/execute.rs b/src/cli/execute.rs index 9ca1300..7126862 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -1194,8 +1194,8 @@ fn map_mcp_ws_args( Some(crate::use_cases::mcp_ws::McpClientTransport::Ws) => { Some(McpClientTransportRequest::Ws) } - Some(crate::use_cases::mcp_ws::McpClientTransport::Legacy) => { - Some(McpClientTransportRequest::Legacy) + Some(crate::use_cases::mcp_ws::McpClientTransport::Mcp) => { + Some(McpClientTransportRequest::Mcp) } Some(crate::use_cases::mcp_ws::McpClientTransport::Auto) => { Some(McpClientTransportRequest::Auto) @@ -1203,7 +1203,7 @@ fn map_mcp_ws_args( None => { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - format!("--mcp-transport must be one of: ws, legacy, auto (got: {value})"), + format!("--mcp-transport must be one of: ws, mcp, auto (got: {value})"), )); } }, @@ -1225,6 +1225,12 @@ fn map_mcp_ws_args( )); } if let Some(url) = args.manager_url.as_deref() { + if !crate::use_cases::mcp_ws::is_payload_token_safe(url) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--manager-url must not contain ';' or '=' because the /C payload is semicolon-delimited", + )); + } if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { return Err(UseCaseError::new( UseCaseErrorKind::Validation, @@ -1233,18 +1239,18 @@ fn map_mcp_ws_args( } } if let Some(uid) = args.client_uid.as_deref() { - if uid.contains(';') { + if !crate::use_cases::mcp_ws::is_payload_token_safe(uid) { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - "--client-uid must not contain ';' because the /C payload is semicolon-delimited", + "--client-uid must not contain ';' or '=' because the /C payload is semicolon-delimited", )); } } if let Some(corr) = args.corr_id.as_deref() { - if corr.contains(';') { + if !crate::use_cases::mcp_ws::is_payload_token_safe(corr) { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - "--corr-id must not contain ';' because the /C payload is semicolon-delimited", + "--corr-id must not contain ';' or '=' because the /C payload is semicolon-delimited", )); } } diff --git a/src/config/model.rs b/src/config/model.rs index e5b7c1b..901b405 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -287,12 +287,12 @@ pub struct ClientMcpToolConfig { /// Optional tool extension prepared by `build` for client MCP launches. pub extension: Option, - /// Default transport for the MCP client side: `ws`, `legacy` or `auto`. + /// Default transport for the MCP client side: `ws`, `mcp` or `auto`. /// When omitted, runtime treats it as `auto` (probe manager, fall back - /// to legacy local HTTP MCP). + /// to local HTTP MCP). pub transport: Option, - /// Default WS endpoint for the session-manager + /// Default WS endpoint with IP address and port for the session-manager /// (e.g. `ws://127.0.0.1:4000/sessions`). pub manager_url: Option, diff --git a/src/config/schema.rs b/src/config/schema.rs index b00d18c..3eda552 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -27,6 +27,7 @@ pub fn main_config_schema_json() -> Value { set_schema_id(&mut schema, &main_config_schema_url()); add_tool_extension_schema_constraints(&mut schema); add_numeric_runtime_bounds(&mut schema); + add_mcp_transport_schema_constraints(&mut schema); schema } @@ -36,6 +37,7 @@ pub fn local_config_schema_json() -> Value { set_schema_id(&mut schema, &local_config_schema_url()); add_tool_extension_schema_constraints(&mut schema); add_numeric_runtime_bounds(&mut schema); + add_mcp_transport_schema_constraints(&mut schema); schema } @@ -233,6 +235,7 @@ fn add_numeric_runtime_bounds(schema: &mut Value) { ); for def in ["ClientMcpToolSchema", "PartialClientMcpToolSchema"] { set_numeric_bounds(schema, &[def], "port", Some(1), None); + set_numeric_bounds(schema, &[def], "ws_timeout_ms", Some(1), None); } for name in ["max_sessions", "idle_ttl_secs"] { set_numeric_bounds(schema, &["McpHttpSchema"], name, Some(1), None); @@ -279,6 +282,35 @@ fn set_numeric_bounds( } } +fn add_mcp_transport_schema_constraints(schema: &mut Value) { + for def in ["ClientMcpToolSchema", "PartialClientMcpToolSchema"] { + set_string_enum(schema, &[def], "transport", &["ws", "mcp", "auto"]); + } +} + +fn set_string_enum(schema: &mut Value, def_path: &[&str], property: &str, variants: &[&str]) { + let Some(object) = schema_object_mut(schema, def_path) else { + return; + }; + let Some(property) = object + .get_mut("properties") + .and_then(Value::as_object_mut) + .and_then(|properties| properties.get_mut(property)) + .and_then(Value::as_object_mut) + else { + return; + }; + + let mut variants = variants + .iter() + .map(|value| json!(value)) + .collect::>(); + if matches!(property.get("type"), Some(Value::Array(_))) { + variants.push(Value::Null); + } + property.insert("enum".to_owned(), Value::Array(variants)); +} + fn schema_object_mut<'a>( schema: &'a mut Value, def_path: &[&str], @@ -705,10 +737,10 @@ struct ClientMcpToolSchema { /// Optional tool extension prepared by `build` for client MCP launches. #[serde(default, skip_serializing_if = "Option::is_none")] extension: Option, - /// Default transport for the MCP client side: `ws`, `legacy` or `auto`. + /// Default transport for the MCP client side: `ws`, `mcp` or `auto`. #[serde(default, skip_serializing_if = "Option::is_none")] transport: Option, - /// Default WS endpoint for the session-manager. + /// Default WS endpoint with IP address and port for the session-manager. #[serde(default, skip_serializing_if = "Option::is_none")] manager_url: Option, /// Default `mcp_log_level` value forwarded into the `/C` payload. @@ -1460,6 +1492,20 @@ mod tests { } } + #[test] + fn schemas_and_loader_reject_invalid_client_mcp_transport() { + let config = format!( + "{}tools:\n client_mcp:\n transport: legacy\n", + minimal_project_config_without_base_path() + ); + assert_schema_invalid(&main_config_schema_json(), &config); + assert_config_loader_error_any(&config); + + let overlay = "tools:\n client_mcp:\n transport: legacy\n"; + assert_schema_invalid(&local_config_schema_json(), overlay); + assert_overlay_loader_error(overlay); + } + #[test] fn schemas_and_loader_accept_supported_runtime_sections() { let config = format!( diff --git a/src/config/validate.rs b/src/config/validate.rs index bdaa7f5..5a2dc67 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -163,7 +163,7 @@ pub enum ConfigValidationError { #[error("tools.client_mcp.port must be greater than or equal to 1")] InvalidMcpClientPort, - #[error("tools.client_mcp.transport must be one of: ws, legacy, auto (got: {0})")] + #[error("tools.client_mcp.transport must be one of: ws, mcp, auto (got: {0})")] InvalidMcpClientTransport(String), #[error( @@ -850,6 +850,11 @@ fn validate_mcp_config(config: &AppConfig) -> Result<(), ConfigValidationError> } if let Some(url) = config.tools.client_mcp.manager_url.as_deref() { + if !crate::use_cases::mcp_ws::is_payload_token_safe(url) { + return Err(ConfigValidationError::InvalidMcpClientManagerUrl( + url.to_owned(), + )); + } if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { return Err(ConfigValidationError::InvalidMcpClientManagerUrl( url.to_owned(), diff --git a/src/domain/launch.rs b/src/domain/launch.rs index 49e2c76..a0ba964 100644 --- a/src/domain/launch.rs +++ b/src/domain/launch.rs @@ -15,10 +15,10 @@ pub struct LaunchResult { pub binary: PathBuf, /// Human-readable launch summary. pub message: Option, - /// MCP transport selected for this launch (`ws` or `legacy`). + /// MCP transport selected for this launch. /// Present only for `launch mcp` and `test` flows. #[serde(skip_serializing_if = "Option::is_none", default)] - pub transport: Option, + pub transport: Option, /// Per-launch UUID announced to the session-manager /// (`mcpMode=ws` only). #[serde(skip_serializing_if = "Option::is_none", default)] @@ -34,11 +34,19 @@ pub struct LaunchResult { /// (`mcpMode=ws` only). #[serde(skip_serializing_if = "Option::is_none", default)] pub corr_id: Option, - /// Local HTTP MCP port (`legacy` transport only). + /// Local HTTP MCP port (`mcp` transport only). #[serde(skip_serializing_if = "Option::is_none", default)] pub mcp_port: Option, } +/// MCP client transport selected for a launch result. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LaunchMcpTransport { + Ws, + Mcp, +} + /// Supported application launch modes. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index a73e22f..d055c11 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::time::Duration; use crate::config::model::AppConfig; -use crate::domain::launch::{LaunchMode, LaunchResult}; +use crate::domain::launch::{LaunchMcpTransport, LaunchMode, LaunchResult}; use crate::domain::runner::LaunchOptions; use crate::platform::enterprise::{ build_launch_args, normalize_launch_payload_path, LaunchClientMode, @@ -24,7 +24,7 @@ use crate::use_cases::request::{ }; use crate::use_cases::result::{UseCaseFailure, UseCaseResult}; use crate::use_cases::tool_extension; -use tracing::debug; +use tracing::{debug, warn}; const LAUNCH_STARTUP_PROBE: Duration = Duration::from_millis(250); @@ -119,14 +119,14 @@ pub fn execute( fn apply_mcp_resolution_to_result(result: &mut LaunchResult, meta: McpResolutionMeta) { match meta { McpResolutionMeta::Ws(params) => { - result.transport = Some("ws".to_owned()); + result.transport = Some(LaunchMcpTransport::Ws); result.client_uid = Some(params.client_uid); result.kind = Some(params.kind.as_str().to_owned()); result.manager_url = Some(params.manager_url); result.corr_id = Some(params.corr_id); } - McpResolutionMeta::Legacy { port } => { - result.transport = Some("legacy".to_owned()); + McpResolutionMeta::Mcp { port } => { + result.transport = Some(LaunchMcpTransport::Mcp); result.mcp_port = port; } } @@ -135,7 +135,7 @@ fn apply_mcp_resolution_to_result(result: &mut LaunchResult, meta: McpResolution #[derive(Debug, Clone)] enum McpResolutionMeta { Ws(WsLaunchParams), - Legacy { port: Option }, + Mcp { port: Option }, } fn launch_message(config: &AppConfig, args: &LaunchArgs, binary: &Path, pid: u32) -> String { @@ -245,14 +245,22 @@ fn effective_launch_options( let mut launch = args.launch.clone(); let (mut payload, meta) = match decision { TransportDecision::Ws => { + if client_mcp.port.is_some() || config.tools.client_mcp.port.is_some() { + warn!( + "ws transport: client_mcp.port is ignored; session-manager controls the port" + ); + } + if client_mcp.config_path.is_some() { + warn!("ws transport: client_mcp.config_path is ignored"); + } let params = resolve_ws_launch_params(config, &args.mcp_ws, kind); let snippet = params.payload_snippet(); (snippet, McpResolutionMeta::Ws(params)) } - TransportDecision::Legacy => { + TransportDecision::Mcp => { let payload = build_legacy_client_mcp_payload(client_mcp, config.tools.client_mcp.port); let port = client_mcp.port.or(config.tools.client_mcp.port); - (payload, McpResolutionMeta::Legacy { port }) + (payload, McpResolutionMeta::Mcp { port }) } }; if matches!( @@ -308,7 +316,7 @@ pub(crate) fn effective_transport( if let Some(t) = cli.transport { return match t { McpClientTransportRequest::Ws => McpClientTransport::Ws, - McpClientTransportRequest::Legacy => McpClientTransport::Legacy, + McpClientTransportRequest::Mcp => McpClientTransport::Mcp, McpClientTransportRequest::Auto => McpClientTransport::Auto, }; } @@ -354,6 +362,7 @@ mod tests { SourceFormat, SourceSetConfig, SourceSetPurpose, TestsConfig, ToolExtensionArtifactConfig, ToolExtensionConfig, ToolExtensionInput, ToolsConfig, }; + use crate::domain::launch::LaunchMcpTransport; use crate::use_cases::context::{CommandName, ExecutionContext}; use crate::use_cases::request::{ ClientMcpMode, ClientMcpOptionsRequest, LaunchRequest, LaunchTargetRequest, @@ -533,7 +542,7 @@ mod tests { launch: Default::default(), client_mcp: Some(ClientMcpOptionsRequest::default()), mcp_ws: crate::use_cases::request::McpClientWsRequest { - transport: Some(crate::use_cases::request::McpClientTransportRequest::Legacy), + transport: Some(crate::use_cases::request::McpClientTransportRequest::Mcp), ..Default::default() }, }, @@ -546,7 +555,7 @@ mod tests { .as_deref() .expect("message") .contains("v8-runner build")); - assert_eq!(result.transport.as_deref(), Some("legacy")); + assert_eq!(result.transport, Some(LaunchMcpTransport::Mcp)); assert_eq!(result.mcp_port, Some(9874)); let args = fs::read_to_string(args_log).expect("args log"); assert!(args.contains("ENTERPRISE")); diff --git a/src/use_cases/mcp_ws.rs b/src/use_cases/mcp_ws.rs index 3c39bc1..9551422 100644 --- a/src/use_cases/mcp_ws.rs +++ b/src/use_cases/mcp_ws.rs @@ -1,11 +1,11 @@ //! Shared helpers for assembling the WS-mode `/C` payload that connects -//! 1C-clients to `v8-client-session-manager` instead of the legacy local +//! 1C-clients to `v8-client-session-manager` instead of the local //! HTTP MCP server. //! //! The actual `/C` payload is parsed by the BSL extension `client_mcp` (see //! `Мсп_ПараметрыЗапускаКлиент`). This module is responsible for: //! -//! * choosing between the new WS transport and the legacy HTTP transport, +//! * choosing between the new WS transport and the local HTTP MCP transport, //! * probing the manager's TCP socket when the transport is `auto`, //! * generating per-launch `client_uid`/`corr_id` values, //! * and serializing the final `key=value;...` snippet. @@ -13,7 +13,7 @@ //! Higher layers (`launch_app`, `run_tests`) decide where this snippet is //! merged into the final `/C` value. -use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::net::{IpAddr, SocketAddr, TcpStream}; use std::time::Duration; use uuid::Uuid; @@ -33,9 +33,9 @@ pub const PROBE_TIMEOUT_MS: u64 = 200; pub enum McpClientTransport { /// Force WS-only mode. Resolution fails if the manager is unreachable. Ws, - /// Force the legacy local HTTP transport (`runMcp[=...][;mcpPort=...]`). - Legacy, - /// Probe the manager: WS when reachable, legacy otherwise. + /// Force the local HTTP MCP transport (`runMcp[=...][;mcpPort=...]`). + Mcp, + /// Probe the manager: WS when reachable, local HTTP MCP otherwise. #[default] Auto, } @@ -44,7 +44,7 @@ impl McpClientTransport { pub fn from_str_value(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "ws" => Some(Self::Ws), - "legacy" => Some(Self::Legacy), + "mcp" => Some(Self::Mcp), "auto" => Some(Self::Auto), _ => None, } @@ -75,7 +75,15 @@ const ALLOWED_LOG_LEVELS: &[&str] = &["off", "error", "warn", "info", "debug", " /// Returns `true` when the value is one of the levels accepted by the devkit. pub fn is_supported_log_level(level: &str) -> bool { - ALLOWED_LOG_LEVELS.contains(&level) + ALLOWED_LOG_LEVELS + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(level)) +} + +/// Returns `true` when a value can be embedded into the semicolon-delimited +/// `/C` payload without changing its key/value structure. +pub fn is_payload_token_safe(value: &str) -> bool { + !value.contains([';', '=']) } /// Result of resolving the WS-mode connection parameters before launch. @@ -95,16 +103,23 @@ impl WsLaunchParams { pub fn payload_snippet(&self) -> String { format!( "mcpMode=ws;manager_url={};client_uid={};kind={};corr_id={};mcp_log_level={};mcp_ws_timeout_ms={}", - self.manager_url, - self.client_uid, + encode_payload_token(&self.manager_url), + encode_payload_token(&self.client_uid), self.kind.as_str(), - self.corr_id, - self.log_level, + encode_payload_token(&self.corr_id), + encode_payload_token(&self.log_level), self.ws_timeout_ms ) } } +fn encode_payload_token(value: &str) -> String { + value + .replace('%', "%25") + .replace(';', "%3B") + .replace('=', "%3D") +} + /// Inputs to [`resolve_ws_params`]. Each field carries either an explicit /// override (CLI takes precedence over config), or `None` to fall back to the /// internal defaults. @@ -139,6 +154,7 @@ pub fn resolve_ws_params(kind: ClientKind, inputs: WsResolveInputs) -> WsLaunchP let log_level = inputs .log_level .filter(|l| !l.trim().is_empty()) + .map(|level| level.to_ascii_lowercase()) .unwrap_or_else(|| DEFAULT_MCP_LOG_LEVEL.to_owned()); let ws_timeout_ms = inputs.ws_timeout_ms.unwrap_or(DEFAULT_MCP_WS_TIMEOUT_MS); WsLaunchParams { @@ -152,7 +168,10 @@ pub fn resolve_ws_params(kind: ClientKind, inputs: WsResolveInputs) -> WsLaunchP } fn default_corr_id(client_uid: &str) -> String { - let short: String = client_uid.chars().filter(|c| *c != '-').take(8).collect(); + let mut short: String = client_uid.chars().filter(|c| *c != '-').take(8).collect(); + while short.len() < 8 { + short.push('0'); + } format!("vr-{short}") } @@ -169,7 +188,7 @@ pub enum WsResolveError { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TransportDecision { Ws, - Legacy, + Mcp, } /// Selects the effective transport given the requested mode and a `probe` @@ -183,7 +202,7 @@ where F: FnOnce(SocketAddr) -> bool, { match requested { - McpClientTransport::Legacy => Ok(TransportDecision::Legacy), + McpClientTransport::Mcp => Ok(TransportDecision::Mcp), McpClientTransport::Ws => { let addr = parse_manager_addr(manager_url)?; if probe(addr) { @@ -199,7 +218,7 @@ where if probe(addr) { Ok(TransportDecision::Ws) } else { - Ok(TransportDecision::Legacy) + Ok(TransportDecision::Mcp) } } } @@ -211,8 +230,11 @@ pub fn probe_tcp(addr: SocketAddr, timeout: Duration) -> bool { TcpStream::connect_timeout(&addr, timeout).is_ok() } -/// Parses the `host:port` portion of a `ws://host:port/path` URL and resolves -/// it to a usable [`SocketAddr`]. Falls back to lookup via `to_socket_addrs`. +/// Parses the `host:port` portion of a `ws://host:port/path` URL. +/// +/// Hostnames are intentionally rejected: transport auto-detection must stay +/// bounded by the 200 ms TCP probe, and synchronous DNS lookup would not share +/// that timeout budget. pub fn parse_manager_addr(url: &str) -> Result { let trimmed = url.trim(); if trimmed.is_empty() { @@ -232,23 +254,26 @@ pub fn parse_manager_addr(url: &str) -> Result { reason: "missing host:port".to_owned(), }); } - if !host_port.contains(':') { + let Some((host, port)) = host_port.rsplit_once(':') else { return Err(WsResolveError::InvalidManagerUrl { url: url.to_owned(), reason: "missing :port".to_owned(), }); - } - host_port - .to_socket_addrs() + }; + let host = host.trim_matches(['[', ']']); + let ip = host + .parse::() + .map_err(|_| WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "host must be an IP address".to_owned(), + })?; + let port = port + .parse::() .map_err(|err| WsResolveError::InvalidManagerUrl { url: url.to_owned(), reason: err.to_string(), - })? - .next() - .ok_or_else(|| WsResolveError::InvalidManagerUrl { - url: url.to_owned(), - reason: "address resolved to empty set".to_owned(), - }) + })?; + Ok(SocketAddr::new(ip, port)) } #[cfg(test)] @@ -263,9 +288,10 @@ mod tests { Some(McpClientTransport::Ws) ); assert_eq!( - McpClientTransport::from_str_value("LEGACY"), - Some(McpClientTransport::Legacy) + McpClientTransport::from_str_value("MCP"), + Some(McpClientTransport::Mcp) ); + assert_eq!(McpClientTransport::from_str_value("legacy"), None); assert_eq!( McpClientTransport::from_str_value("auto"), Some(McpClientTransport::Auto) @@ -292,25 +318,25 @@ mod tests { } #[test] - fn select_transport_legacy_short_circuits() { + fn select_transport_mcp_short_circuits() { let decision = select_transport( - McpClientTransport::Legacy, + McpClientTransport::Mcp, "ws://127.0.0.1:4000/sessions", |_| panic!("probe must not be called"), ) - .expect("legacy"); - assert_eq!(decision, TransportDecision::Legacy); + .expect("mcp"); + assert_eq!(decision, TransportDecision::Mcp); } #[test] - fn select_transport_auto_falls_back_to_legacy_when_unreachable() { + fn select_transport_auto_falls_back_to_mcp_when_unreachable() { let decision = select_transport( McpClientTransport::Auto, "ws://127.0.0.1:4000/sessions", |_| false, ) .expect("auto-fallback"); - assert_eq!(decision, TransportDecision::Legacy); + assert_eq!(decision, TransportDecision::Mcp); } #[test] @@ -344,14 +370,8 @@ mod tests { #[test] fn probe_tcp_fails_when_no_listener() { - // Bind to an ephemeral port and immediately drop the listener; the OS - // will reject connections to that port until reuse, which is enough - // for a unit-test. - let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); - let addr = listener.local_addr().expect("local addr"); - drop(listener); - // Allow a generous timeout — RST should arrive quickly. - let connected = probe_tcp(addr, Duration::from_millis(200)); + let addr = SocketAddr::from(([127, 0, 0, 1], 1)); + let connected = probe_tcp(addr, Duration::from_millis(50)); assert!(!connected); } @@ -385,6 +405,18 @@ mod tests { assert_eq!(params.kind, ClientKind::YaxunitRunner); } + #[test] + fn resolve_ws_params_pads_default_corr_id_for_short_uid() { + let params = resolve_ws_params( + ClientKind::V8RunnerClient, + WsResolveInputs { + client_uid: Some("abc".to_owned()), + ..Default::default() + }, + ); + assert_eq!(params.corr_id, "vr-abc00000"); + } + #[test] fn payload_snippet_contains_all_keys_in_order() { let params = WsLaunchParams { @@ -401,11 +433,28 @@ mod tests { ); } + #[test] + fn payload_snippet_encodes_delimiters() { + let params = WsLaunchParams { + manager_url: "ws://127.0.0.1:1/s".to_owned(), + client_uid: "uid=1".to_owned(), + kind: ClientKind::V8RunnerClient, + corr_id: "corr;1".to_owned(), + log_level: "info".to_owned(), + ws_timeout_ms: 1000, + }; + assert_eq!( + params.payload_snippet(), + "mcpMode=ws;manager_url=ws://127.0.0.1:1/s;client_uid=uid%3D1;kind=v8_runner_client;corr_id=corr%3B1;mcp_log_level=info;mcp_ws_timeout_ms=1000" + ); + } + #[test] fn is_supported_log_level_accepts_known_values() { for level in ["off", "error", "warn", "info", "debug", "trace"] { assert!(is_supported_log_level(level), "expected {level} supported"); } + assert!(is_supported_log_level("INFO")); assert!(!is_supported_log_level("verbose")); } diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index c0dcac6..6524a2c 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -619,7 +619,7 @@ pub struct LaunchRequest { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpClientTransportRequest { Ws, - Legacy, + Mcp, Auto, } diff --git a/src/use_cases/run_tests/coordinator.rs b/src/use_cases/run_tests/coordinator.rs index 6f96ce1..646e95c 100644 --- a/src/use_cases/run_tests/coordinator.rs +++ b/src/use_cases/run_tests/coordinator.rs @@ -228,7 +228,25 @@ pub(super) fn run_tests( let enterprise_runner = crate::platform::process::ProcessExecutor; let mut platform_launch = build_platform_launch(&args.execution.launch, &prepared_run, &artifacts); - apply_test_mcp_ws_payload(config, &args.mcp_ws, &prepared_run, &mut platform_launch); + if let Err(error) = + apply_test_mcp_ws_payload(config, &args.mcp_ws, &prepared_run, &mut platform_launch) + { + let outcome = ExecutionOutcome::new(ExecutionStatus::Failed) + .with_diagnostics(vec![error.to_string()]) + .with_errors(vec![test_execution_error( + TestErrorKind::TestSetupFailed, + error.to_string(), + )]); + let result = make_test_result( + target, + mode, + outcome, + warnings, + steps, + started.elapsed().as_millis() as u64, + ); + return Err(TestExecutionFailure::with_payload(error, result)); + } let enterprise = match build_enterprise_dsl( context, config, diff --git a/src/use_cases/run_tests/helpers.rs b/src/use_cases/run_tests/helpers.rs index 71d6c2b..e0a0f38 100644 --- a/src/use_cases/run_tests/helpers.rs +++ b/src/use_cases/run_tests/helpers.rs @@ -431,32 +431,25 @@ pub(super) fn append_mcp_ws_snippet(launch: &mut LaunchOptions, snippet: &str) { /// `mcpMode=ws;...` snippet to the platform `/C` so the BSL devkit registers /// with `v8-client-session-manager` instead of starting a local HTTP MCP. /// -/// Errors from the resolution layer (e.g. invalid `manager_url`) are logged -/// and treated as "no WS snippet"; the test run still proceeds with its -/// regular `/C` payload. This preserves the previous behavior when the -/// session-manager is not used. +/// Errors from explicit WS resolution are returned to the caller. Auto mode +/// still falls back to the regular `/C` payload when the manager is down. pub(super) fn apply_test_mcp_ws_payload( config: &AppConfig, mcp_ws: &crate::use_cases::request::McpClientWsRequest, prepared_run: &PreparedRun, launch: &mut LaunchOptions, -) { +) -> Result<(), AppError> { let kind = match prepared_run { PreparedRun::YaXUnit => crate::use_cases::mcp_ws::ClientKind::YaxunitRunner, PreparedRun::Vanessa { .. } => crate::use_cases::mcp_ws::ClientKind::VanessaTestClient, }; - let decision = match crate::use_cases::launch_app::decide_mcp_transport(config, mcp_ws) { - Ok(d) => d, - Err(err) => { - tracing::warn!(error = %err, "failed to resolve MCP client transport for test run"); - return; - } - }; + let decision = crate::use_cases::launch_app::decide_mcp_transport(config, mcp_ws)?; if !matches!(decision, crate::use_cases::mcp_ws::TransportDecision::Ws) { - return; + return Ok(()); } let params = crate::use_cases::launch_app::resolve_ws_launch_params(config, mcp_ws, kind); append_mcp_ws_snippet(launch, ¶ms.payload_snippet()); + Ok(()) } pub(super) fn collect_diagnostics( diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index a79549f..532a669 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -384,7 +384,7 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { "--mcp-config", "/tmp/mcp conf.json", "--mcp-transport", - "legacy", + "mcp", "--raw-key", "/WA-", ]) @@ -400,7 +400,7 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { ); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!(payload["data"]["mode"], "mcp"); - assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["transport"], "mcp"); assert_eq!(payload["data"]["mcp_port"], 9874); assert_eq!( payload["data"]["binary"].as_str().expect("binary"), @@ -709,7 +709,7 @@ fn setup_mcp_project_with_logging_thin() -> (tempfile::TempDir, PathBuf, PathBuf } #[test] -fn launch_mcp_legacy_transport_emits_runmcp_payload_and_legacy_envelope() { +fn launch_mcp_transport_emits_runmcp_payload_and_mcp_envelope() { let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); let output = v8_runner_command() .args([ @@ -719,7 +719,7 @@ fn launch_mcp_legacy_transport_emits_runmcp_payload_and_legacy_envelope() { "launch", "mcp", "--mcp-transport", - "legacy", + "mcp", "--mcp-port", "9999", ]) @@ -731,7 +731,7 @@ fn launch_mcp_legacy_transport_emits_runmcp_payload_and_legacy_envelope() { String::from_utf8_lossy(&output.stderr) ); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); - assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["transport"], "mcp"); assert_eq!(payload["data"]["mcp_port"], 9999); assert!(payload["data"]["client_uid"].is_null()); @@ -832,7 +832,7 @@ fn launch_mcp_ws_required_fails_when_manager_unreachable() { } #[test] -fn launch_mcp_auto_falls_back_to_legacy_when_manager_unreachable() { +fn launch_mcp_auto_falls_back_to_mcp_when_manager_unreachable() { let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); let port = listener.local_addr().expect("addr").port(); @@ -854,7 +854,7 @@ fn launch_mcp_auto_falls_back_to_legacy_when_manager_unreachable() { .expect("run command"); assert!(output.status.success()); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); - assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["transport"], "mcp"); let args = fs::read_to_string(args_log).expect("args log"); assert!(args.contains("/C\"runMcp\"")); assert!(!args.contains("mcpMode=ws")); diff --git a/v8-runner/SKILL.md b/v8-runner/SKILL.md index e76caea..9451bfb 100644 --- a/v8-runner/SKILL.md +++ b/v8-runner/SKILL.md @@ -75,7 +75,7 @@ v8-runner init - Release artifacts need to be exported or external artifacts published: use `v8-runner make ...` or the `artifacts` alias. - Need a 1C UI session: use `v8-runner launch designer`, `launch thin`, `launch thick`, or `launch ordinary`. - Need onec-client-mcp-devkit launched inside 1C without VA authoring: use `v8-runner launch mcp ...`. -- Pair the launched 1С-client with a running [v8-client-session-manager](https://github.com/SteelMorgan/v8-client-session-manager) over WebSocket: rely on `--mcp-transport=auto` (default — TCP-probes `manager_url` for 200 ms). Force WS with `--mcp-transport=ws` (fails if manager is down) or skip WS entirely with `--mcp-transport=legacy`. WS-only flags: `--manager-url`, `--client-uid`, `--corr-id`, `--mcp-log-level`, `--mcp-ws-timeout-ms`. The internal `kind` mapping (`v8_runner_client` / `vanessa_test_client` / `yaxunit_runner` / `vanessa_test_client`) is fixed by entry-point and **not** overridable from CLI. Read `references/project-workflows.md` (section «WS-режим к session-manager») for the full payload, defaults, and `--json-message` shape. +- Pair the launched 1С-client with a running [v8-client-session-manager](https://github.com/SteelMorgan/v8-client-session-manager) over WebSocket: rely on `--mcp-transport=auto` (default — TCP-probes `manager_url` for 200 ms). Force WS with `--mcp-transport=ws` (fails if manager is down) or use local HTTP MCP with `--mcp-transport=mcp`. WS-only flags: `--manager-url`, `--client-uid`, `--corr-id`, `--mcp-log-level`, `--mcp-ws-timeout-ms`. The internal `kind` mapping (`v8_runner_client` / `vanessa_test_client` / `yaxunit_runner` / `vanessa_test_client`) is fixed by entry-point and **not** overridable from CLI. Read `references/project-workflows.md` (section «WS-режим к session-manager») for the full payload, defaults, and `--json-message` shape. ## Guardrails diff --git a/v8-runner/references/bootstrap.md b/v8-runner/references/bootstrap.md index 29b6288..265136d 100644 --- a/v8-runner/references/bootstrap.md +++ b/v8-runner/references/bootstrap.md @@ -45,7 +45,7 @@ Ask the user only if the choice is ambiguous and you cannot tell from `tools.pla ### 4. Decide infobase connection -`--connection` is the connection string written into `tools.connection`. Three common shapes: +`--connection` is the connection string written into `infobase.connection`. Three common shapes: | Shape | Example | When to use | |---|---|---| diff --git a/v8-runner/references/command-selection.md b/v8-runner/references/command-selection.md index 63935fb..a989f5b 100644 --- a/v8-runner/references/command-selection.md +++ b/v8-runner/references/command-selection.md @@ -166,8 +166,8 @@ WS-mode flags (when v8-client-session-manager is reachable): ```bash v8-runner launch mcp --mcp-transport=ws --manager-url ws://127.0.0.1:4000/sessions -v8-runner launch mcp --mcp-transport=legacy # force legacy without probe +v8-runner launch mcp --mcp-transport=mcp # force local MCP without probe v8-runner launch mcp --mcp-log-level=debug --client-uid --corr-id ``` -`--mcp-transport=auto` (default) probes `manager_url` for 200 ms and chooses `ws` on success, `legacy` on failure. The same WS-flags work on `test yaxunit ...` and `test va ...`. See `project-workflows.md` for the full WS-режим section, internal `kind` mapping, and `--json-message` output shape. +`--mcp-transport=auto` (default) probes `manager_url` for 200 ms and chooses `ws` on success, `mcp` on failure. The same WS-flags work on `test yaxunit ...` and `test va ...`. See `project-workflows.md` for the full WS-режим section, internal `kind` mapping, and `--json-message` output shape. diff --git a/v8-runner/references/project-workflows.md b/v8-runner/references/project-workflows.md index 9786ef1..513236c 100644 --- a/v8-runner/references/project-workflows.md +++ b/v8-runner/references/project-workflows.md @@ -135,17 +135,17 @@ For `launch mcp va`, read `testing.md`; it is part of the Vanessa Automation deb ## WS-режим к session-manager -Когда рядом с проектом запущен [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager), 1С-клиент может подключаться к нему по WebSocket вместо локального HTTP MCP-сервера (legacy `runMcp`-режим). v8-runner делает выбор автоматически. +Когда рядом с проектом запущен [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager), 1С-клиент может подключаться к нему по WebSocket вместо локального HTTP MCP-сервера (`runMcp`-режим). v8-runner делает выбор автоматически. ### Транспорт и автоопределение `tools.client_mcp.transport`: -- `auto` (по умолчанию) — короткий TCP-probe (200 ms) на хост:порт из `manager_url`. Слышим listener → WS, нет → legacy. +- `auto` (по умолчанию) — короткий TCP-probe (200 ms) на IP:порт из `manager_url`. Слышим listener → WS, нет → MCP. - `ws` — строго WS, при недоступности менеджера запуск падает с `session-manager unreachable at `. -- `legacy` — старый HTTP-режим без probe. +- `mcp` — локальный HTTP MCP-режим без probe. -Override через `--mcp-transport={ws|legacy|auto}`. CLI приоритет конфига. +Override через `--mcp-transport={ws|mcp|auto}`. CLI приоритет конфига. ### Что v8-runner подставляет в `/C` в WS-ветке @@ -187,13 +187,13 @@ WS-ветка: ```json { "transport": "ws", "client_uid": "...", "kind": "...", "manager_url": "...", "corr_id": "..." } ``` -Legacy-ветка: +MCP-ветка: ```json -{ "transport": "legacy", "mcp_port": 9874 } +{ "transport": "mcp", "mcp_port": 9874 } ``` Внешний оркестратор (CI, AI-агент) использует `client_uid` для поиска сессии в `session_list` менеджера. ### Менеджер не запускается из v8-runner -v8-runner только подключается к запущенному менеджеру. Подъём менеджера — отдельный шаг (`cargo run --release` в репо `v8-client-session-manager`, либо systemd-юнит `systemd/v8-session-manager.service`, либо Docker-compose). Если менеджер не нужен — `--mcp-transport=legacy` форсирует старый flow. +v8-runner только подключается к запущенному менеджеру. Подъём менеджера — отдельный шаг (`cargo run --release` в репо `v8-client-session-manager`, либо systemd-юнит `systemd/v8-session-manager.service`, либо Docker-compose). Если менеджер не нужен — `--mcp-transport=mcp` форсирует локальный MCP flow. From cbaf405442a07a4e79dd3664d96019d2620fc0d6 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Wed, 13 May 2026 13:10:10 +0000 Subject: [PATCH 13/16] fix(mcp): address transport review notes - clarify WS timeout documentation\n- include manager_url parse diagnostics\n- remove legacy wording from public docs --- docs/CONFIGURATION.md | 4 ++-- src/cli/execute.rs | 4 ++-- v8-runner/references/bootstrap.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4ab7a49..61443f8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -454,8 +454,8 @@ runtime identity и не добавляет отдельное поле конф по умолчанию `ws://127.0.0.1:4000/sessions`. - `log_level` (`off`/`error`/`warn`/`info`/`debug`/`trace`) — значение `mcp_log_level`, передаваемое в `/C` BSL-расширению `client_mcp`. -- `ws_timeout_ms` — значение `mcp_ws_timeout_ms` (таймаут установки WS-сессии в режиме `auto`, - > 0). +- `ws_timeout_ms` — значение `mcp_ws_timeout_ms`, таймаут установки WS-сессии + в миллисекундах (> 0). `launch mcp` передаёт `port` как `mcpPort` внутри `/C"runMcp..."` если CLI не указал `--mcp-port` и выбран `mcp`/`auto`-fallback транспорт. diff --git a/src/cli/execute.rs b/src/cli/execute.rs index 7126862..5310a91 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -1231,10 +1231,10 @@ fn map_mcp_ws_args( "--manager-url must not contain ';' or '=' because the /C payload is semicolon-delimited", )); } - if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { + if let Err(error) = crate::use_cases::mcp_ws::parse_manager_addr(url) { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - format!("--manager-url must include host:port (got: {url})"), + format!("--manager-url parse error: {error}"), )); } } diff --git a/v8-runner/references/bootstrap.md b/v8-runner/references/bootstrap.md index 265136d..557659b 100644 --- a/v8-runner/references/bootstrap.md +++ b/v8-runner/references/bootstrap.md @@ -39,7 +39,7 @@ If the format is unambiguously one or the other, you may pass `--format=designer Defaults to `DESIGNER`. Switch to `IBCMD` only when: - the project is `EDT` and the team is on a platform version where `ibcmd` is supported and faster (≥ 8.3.20); **and** -- there is no Designer-only feature on the critical path (some legacy project tasks still need the Designer GUI). +- there is no Designer-only feature on the critical path (some older project tasks still need the Designer GUI). Ask the user only if the choice is ambiguous and you cannot tell from `tools.platform.version`. Default to `DESIGNER` when in doubt — it is the safer baseline. From e4b58459c2cc1c0f3f90b00f271a1bf6c921601f Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Wed, 13 May 2026 13:52:33 +0000 Subject: [PATCH 14/16] fix(build): address dynamic update review notes - force static update for test prerequisite builds - cover dynamic update mapping for MCP and EDT Designer builds - refresh config schemas and docs for dynamic update behavior --- SKILL/SKILL.md | 4 +- docs/CAPABILITIES.md | 4 +- docs/DEEP_DIVE.md | 4 +- docs/schemas/v8project.local.schema.json | 7 ++++ docs/schemas/v8project.schema.json | 11 +++++ src/config/loader.rs | 3 +- src/mcp/service.rs | 3 +- src/use_cases/build_project.rs | 53 ++++++++++++++++++++++++ src/use_cases/run_tests/coordinator.rs | 6 +-- 9 files changed, 86 insertions(+), 9 deletions(-) diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 3af7f61..0ad5bf1 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -68,7 +68,9 @@ v8-runner init - Only one source-set changed: use commands that accept `--source-set ` instead of rebuilding or materializing everything. - Branch switch, rebase, large object moves, stale source-backed tool extension state, or suspicious incremental state: run `v8-runner build --full-rebuild`. - Syntax check: inspect `format` and `builder`, then choose `syntax designer-modules`, `syntax designer-config`, or `syntax edt`. -- Behavior validation: run the relevant `v8-runner test ...` command; tests build first. +- Behavior validation: run the relevant `v8-runner test ...` command; tests build first with a + static `/UpdateDBCfg`. Use `v8-runner build --dynamic` before tests when dynamic preparation is + intentional. - Missing local YAxUnit, Vanessa Automation, or onec-client-mcp-devkit setup: run `v8-runner tools download yaxunit --sources`, `v8-runner tools download vanessa`, and `v8-runner tools download client-mcp --sources` for source-backed setup. Omit diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index dd9ddb8..071e538 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -175,7 +175,9 @@ v8-runner test va v8-runner test va --feature login --filter-tag @smoke ``` -- Всегда сначала запускает `build`. +- Всегда сначала запускает `build` со статическим `/UpdateDBCfg`, даже если + `build.dynamicUpdate: true`. Для динамической подготовки перед тестами выполните отдельный + `v8-runner build --dynamic`. - `test yaxunit module ` требует непустое имя модуля. - `test va` использует профиль из `tests.va.profile`; `--feature`, `--filter-tag`, `--ignore-tag` и `--scenario-filter` переопределяют соответствующие списки выбранного профиля diff --git a/docs/DEEP_DIVE.md b/docs/DEEP_DIVE.md index 9295445..325a0f9 100644 --- a/docs/DEEP_DIVE.md +++ b/docs/DEEP_DIVE.md @@ -66,7 +66,9 @@ runtime snapshot commit только указанным source-set. `test` и `syntax` проектируются как часть того же локального цикла, а не как отдельная эксплуатационная подсистема. -- `test` всегда сначала делает `build`, затем запускает YaXUnit или Vanessa Automation. +- `test` всегда сначала делает `build` со статическим `/UpdateDBCfg`, затем запускает YaXUnit или + Vanessa Automation. Динамическая подготовка перед тестами выполняется отдельным + `build --dynamic`. - `syntax designer-*` работает только для `DESIGNER` source format. - `syntax edt` использует EDT `validate` и привязан к `format=EDT`. - Таймауты и interruption metadata должны проходить через общий command-level contract, а не diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index f2c1ec2..a6dab67 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -242,6 +242,13 @@ "null" ] }, + "unlock_code": { + "description": "Optional local infobase unlock code propagated as `/UC `. Masked in command logs.", + "type": [ + "string", + "null" + ] + }, "user": { "description": "Optional local infobase user name.", "type": [ diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index a96fc4f..29b4cbf 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -3,6 +3,10 @@ "BuildSchema": { "additionalProperties": false, "properties": { + "dynamicUpdate": { + "description": "Default `/UpdateDBCfg -Dynamic+` toggle for `build`. CLI `--dynamic` overrides this.", + "type": "boolean" + }, "partialLoadThreshold": { "description": "Maximum changed-file count for partial Designer load before falling back to full load.", "format": "uint", @@ -198,6 +202,13 @@ "null" ] }, + "unlock_code": { + "description": "Optional unlock code propagated as `/UC ` to DESIGNER. Masked in command logs.", + "type": [ + "string", + "null" + ] + }, "user": { "description": "Optional infobase user name passed to platform utilities.", "type": [ diff --git a/src/config/loader.rs b/src/config/loader.rs index 8416c4a..a04b865 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -726,8 +726,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\n unlock_code: seal-1\nbuild:\n dynamicUpdate: true\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\n unlock_code: seal-1\nbuild:\n dynamicUpdate: true\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) diff --git a/src/mcp/service.rs b/src/mcp/service.rs index 0eaba04..8dca840 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -824,7 +824,7 @@ mod tests { &McpBuildProjectRequest { full_rebuild: Some(true), source_set: Some("main".to_owned()), - dynamic_update: None, + dynamic_update: Some(true), }, ) .expect("success"); @@ -840,6 +840,7 @@ mod tests { assert_eq!(requests[0].0.transport(), ExecutionTransport::McpStdio); assert_eq!(requests[0].1.full_rebuild, true); assert_eq!(requests[0].1.source_set.as_deref(), Some("main")); + assert_eq!(requests[0].1.dynamic_update, Some(true)); } #[test] diff --git a/src/use_cases/build_project.rs b/src/use_cases/build_project.rs index c0092bb..6086b90 100644 --- a/src/use_cases/build_project.rs +++ b/src/use_cases/build_project.rs @@ -2717,6 +2717,59 @@ mod tests { assert!(!calls_text.contains("-Dynamic")); } + #[cfg(unix)] + #[test] + fn edt_build_designer_dynamic_update_modes_reach_update_db_cfg() { + for (case, config_dynamic_update, arg_dynamic_update, expected_dynamic) in [ + ("cli_dynamic", false, Some(true), true), + ("config_default_dynamic", true, None, true), + ("cli_static_overrides_config", true, Some(false), false), + ("static_default", false, None, false), + ] { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let platform_script = dir.path().join("platform").join("bin").join("1cv8"); + let edt_script = dir.path().join("edt").join("1cedtcli"); + let designer_calls = dir.path().join(format!("{case}-designer-calls.log")); + let edt_calls = dir.path().join(format!("{case}-edt-calls.log")); + create_source_tree(&base); + write_designer_script(&platform_script, &designer_calls, None); + write_edt_script(&edt_script, &edt_calls, None); + let mut config = + build_edt_config(&base, &work, &dir.path().join("platform"), &edt_script); + config.build.dynamic_update = config_dynamic_update; + prime_edt_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + format!("procedure Test()\n // changed in {case}\nendprocedure"), + ) + .expect("modify edt main"); + + let result = run_build( + &config, + &BuildArgs { + full_rebuild: false, + source_set: None, + dynamic_update: arg_dynamic_update, + }, + ) + .expect(case); + let calls_text = fs::read_to_string(&designer_calls).expect("designer calls"); + + assert!(result.ok, "{case}"); + assert!(calls_text.contains("/UpdateDBCfg"), "{case}"); + assert_eq!( + calls_text.contains("-Dynamic+"), + expected_dynamic, + "{case}: {calls_text}" + ); + } + } + #[cfg(unix)] #[test] fn infobase_unlock_code_propagates_to_designer_args() { diff --git a/src/use_cases/run_tests/coordinator.rs b/src/use_cases/run_tests/coordinator.rs index 9e29c12..9f72195 100644 --- a/src/use_cases/run_tests/coordinator.rs +++ b/src/use_cases/run_tests/coordinator.rs @@ -76,9 +76,9 @@ pub(super) fn run_tests( &BuildArgs { full_rebuild: false, source_set: None, - // `test` always rebuilds with the project default (`build.dynamicUpdate`); the - // ergonomic CLI for one-shot dynamic remains `build --dynamic && test`. - dynamic_update: None, + // Test prerequisite builds must stay static even when the project default enables + // dynamic updates; use `build --dynamic && test` for one-shot dynamic preparation. + dynamic_update: Some(false), }, ) { Ok(result) => result, From 20db5398af39f4cddaa015ce46386a04a1635b6a Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Wed, 13 May 2026 15:48:26 +0000 Subject: [PATCH 15/16] docs(config): clarify unlock code empty value - document that empty unlock_code omits /UC - refresh generated config schemas --- SKILL/references/config-and-backends.md | 2 +- docs/CONFIGURATION.md | 7 ++++--- docs/schemas/v8project.local.schema.json | 2 +- docs/schemas/v8project.schema.json | 2 +- src/config/schema.rs | 6 ++++-- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/SKILL/references/config-and-backends.md b/SKILL/references/config-and-backends.md index cc0ea10..3d9e512 100644 --- a/SKILL/references/config-and-backends.md +++ b/SKILL/references/config-and-backends.md @@ -10,7 +10,7 @@ settings before CLI overrides. - `format`: `DESIGNER` or `EDT`. - `builder`: `DESIGNER` or `IBCMD`. - `infobase.connection`: often `File=build/ib` for local automation. -- `infobase.unlock_code`: optional infobase locking code, propagated to DESIGNER as `/UC `. Required when the configuration was sealed with "Установить пароль"; masked in logs. Place this in `v8project.local.yaml` together with `infobase.password`. +- `infobase.unlock_code`: optional infobase locking code. Non-empty values are propagated to DESIGNER as `/UC `; an empty string omits `/UC`. Required when the configuration was sealed with "Установить пароль"; masked in logs. Place this in `v8project.local.yaml` together with `infobase.password`. - `build.dynamicUpdate`: project-wide default for `/UpdateDBCfg -Dynamic+`. Off by default. CLI `build --dynamic` overrides it for a single invocation. - `source-set`: ordered configuration and extension sources. - `tools.platform.path` or `tools.platform.version`: 1C platform discovery hints. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 614a5e7..e0fcbab 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -127,7 +127,7 @@ infobase: connection: "File=build/ib" user: Admin password: secret - unlock_code: seal-42 # optional `/UC <значение>` для запароленных конфигураций + unlock_code: seal-42 # non-empty value is passed as `/UC <значение>` source-set: - name: main @@ -323,8 +323,9 @@ Credentials самой информационной базы. Кодовое слово (`Конфигурация → Установить пароль`), которое транслируется в DESIGNER как `/UC <значение>`. Без него платформа отказывается выполнять административные операции на -запароленных конфигурациях. Значение маскируется в логах команд (`/UC ***`), поэтому его -безопасно держать в `v8project.local.yaml` рядом с `infobase.password`. +запароленных конфигурациях. Пустая строка считается отсутствием кода и не добавляет `/UC`. +Значение маскируется в логах команд (`/UC ***`), поэтому его безопасно держать в +`v8project.local.yaml` рядом с `infobase.password`. #### `infobase.dbms` diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index a6dab67..cd495b3 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -243,7 +243,7 @@ ] }, "unlock_code": { - "description": "Optional local infobase unlock code propagated as `/UC `. Masked in command logs.", + "description": "Optional local infobase unlock code. Non-empty value is propagated as `/UC `;\nempty string means no unlock code and `/UC` is not passed. Masked in command logs.", "type": [ "string", "null" diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index 29b4cbf..c936da3 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -203,7 +203,7 @@ ] }, "unlock_code": { - "description": "Optional unlock code propagated as `/UC ` to DESIGNER. Masked in command logs.", + "description": "Optional unlock code. Non-empty value is propagated as `/UC ` to DESIGNER;\nempty string means no unlock code and `/UC` is not passed. Masked in command logs.", "type": [ "string", "null" diff --git a/src/config/schema.rs b/src/config/schema.rs index bc8488a..e2fb0af 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -441,7 +441,8 @@ struct InfobaseSchema { /// Optional infobase password passed to platform utilities. #[serde(default, skip_serializing_if = "Option::is_none")] password: Option, - /// Optional unlock code propagated as `/UC ` to DESIGNER. Masked in command logs. + /// Optional unlock code. Non-empty value is propagated as `/UC ` to DESIGNER; + /// empty string means no unlock code and `/UC` is not passed. Masked in command logs. #[serde(default, skip_serializing_if = "Option::is_none")] unlock_code: Option, /// Optional DBMS settings for server-based infobases. @@ -466,7 +467,8 @@ struct PartialInfobaseSchema { /// Optional local infobase password. #[serde(default, skip_serializing_if = "Option::is_none")] password: Option, - /// Optional local infobase unlock code propagated as `/UC `. Masked in command logs. + /// Optional local infobase unlock code. Non-empty value is propagated as `/UC `; + /// empty string means no unlock code and `/UC` is not passed. Masked in command logs. #[serde(default, skip_serializing_if = "Option::is_none")] unlock_code: Option, /// Optional local DBMS settings override. From 19ac7a5e9a498e7fd599cfb7f8374f7bc3889871 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Wed, 13 May 2026 15:28:34 +0000 Subject: [PATCH 16/16] fix(partial-load): include metadata XML descriptor and full object tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При partial-load Designer выгрузки в Hierarchical-формате конфигуратор определяет тип и имя объекта по XML-дескриптору (`/.xml`), который лежит на уровень выше каталога объекта. Старый код добавлял в list-файл только сам BSL (плюс sibling `Module.xml` рядом с BSL, которого в Designer-формате не существует), поэтому Designer трактовал строку `CommonModules//Ext/Module.bsl` как property-путь Configuration и падал с ошибкой «Свойство не входит в состав объекта метаданных Configuration». Что сделано: - Введён whitelist 43 типов метаданных верхнего уровня (CommonModules, Documents, Catalogs, …). - На каждое BSL-изменение находится корневой объект `/` и в list добавляются: * XML-дескриптор `/.xml` * рекурсивно все файлы внутри каталога объекта (включая формы, дополнительные модули, шаблоны). - Старая EDT-ориентированная логика `sibling_xml`/`object_dir` удалена вместе с синтетическими тестами на `Catalogs.Items/...` — partial-load всегда работает в Hierarchical Designer-формате (в EDT-режиме конвертация EDT→Designer выполняется выше, в `plan_edt_export_step`). - Обновлены unit-тесты: добавлены кейсы для CommonModule, Document ObjectModule, Form Module, BSL вне whitelist + сохранены проверки threshold/deleted/Configuration.xml/symlink-escape. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/change_detection/partial_load.rs | 444 +++++++++++++++++++++------ 1 file changed, 348 insertions(+), 96 deletions(-) diff --git a/src/change_detection/partial_load.rs b/src/change_detection/partial_load.rs index 97e5ca9..9342bed 100644 --- a/src/change_detection/partial_load.rs +++ b/src/change_detection/partial_load.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use crate::change_detection::analyzer::{ChangeKind, FileChange}; @@ -9,6 +9,61 @@ pub const DEFAULT_PARTIAL_LOAD_THRESHOLD: usize = 20; /// The name of the root configuration descriptor — if changed, partial load is forbidden. const CONFIGURATION_XML: &str = "Configuration.xml"; +/// Top-level metadata-type directory names in the 1C Designer Hierarchical layout. +/// +/// When a `.bsl` change is located under `///...`, the +/// owning XML descriptor lives at `//.xml` (one level +/// above the object directory) and the related files for the object live under +/// `///`. Without this whitelist we cannot reliably +/// reconstruct the owning object from a deeply nested BSL path (think +/// `Documents//Forms/
/Ext/Form/Module.bsl`). +const METADATA_TYPES: &[&str] = &[ + "AccountingRegisters", + "AccumulationRegisters", + "BusinessProcesses", + "CalculationRegisters", + "Catalogs", + "ChartsOfAccounts", + "ChartsOfCalculationTypes", + "ChartsOfCharacteristicTypes", + "CommandGroups", + "CommonAttributes", + "CommonCommands", + "CommonForms", + "CommonModules", + "CommonPictures", + "CommonTemplates", + "Constants", + "DataProcessors", + "DefinedTypes", + "DocumentJournals", + "Documents", + "Enums", + "EventSubscriptions", + "ExchangePlans", + "ExternalDataSources", + "FilterCriteria", + "FunctionalOptions", + "FunctionalOptionsParameters", + "HTTPServices", + "InformationRegisters", + "Interfaces", + "Languages", + "Reports", + "Roles", + "ScheduledJobs", + "Sequences", + "SessionParameters", + "SettingsStorages", + "StyleItems", + "Styles", + "Subsystems", + "Tasks", + "WSReferences", + "WebServices", + "XDTOPackages", +]; + /// Decision made by [`decide`]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LoadDecision { @@ -52,7 +107,7 @@ pub fn decide(changes: &[FileChange], source_root: &Path, threshold: usize) -> L /// Write a partial-load list file (UTF-8, one path per line, no empty lines). /// /// Paths are written relative to `source_root` as required by Designer's -/// `-listFile` parameter when running in agent mode. +/// `-listFile` parameter. pub fn write_list_file(paths: &[PathBuf], source_root: &Path, dest: &Path) -> std::io::Result<()> { let rel_paths = relative_paths(paths, source_root)?; let lines = rel_paths @@ -98,16 +153,29 @@ fn expand_files(changes: &[FileChange], source_root: &Path) -> Option не входит в состав объекта метаданных Configuration". + // So for every changed BSL we resolve /// + // and add the XML descriptor plus everything inside the object directory. + if !is_bsl(&change.path) { + continue; + } + + let Some(owner) = locate_metadata_object(&change.path, source_root) else { + continue; + }; + + push_if_safe_if_exists(&mut paths, &owner.xml, source_root, &root_real)?; - if let Some(object_dir) = object_dir(&change.path, source_root) { - push_if_safe_if_exists(&mut paths, &object_dir, source_root, &root_real)?; - } + if owner.dir.is_dir() { + collect_object_directory(&mut paths, &owner.dir, source_root, &root_real)?; } } @@ -116,6 +184,90 @@ fn expand_files(changes: &[FileChange], source_root: &Path) -> Option//.xml` — the XML descriptor Designer + /// uses to recognise the object's type and name. + xml: PathBuf, + /// `///` — contents recurse here for forms, + /// templates, manager/object modules, etc. + dir: PathBuf, +} + +/// Walks the relative path components looking for the first known metadata-type +/// directory followed by an object name. Returns the XML descriptor + object +/// directory pair, or `None` if the file does not sit inside a recognised +/// metadata container. +fn locate_metadata_object(bsl: &Path, source_root: &Path) -> Option { + let relative = bsl.strip_prefix(source_root).ok()?; + let components: Vec<&str> = relative + .components() + .filter_map(|component| match component { + Component::Normal(part) => part.to_str(), + _ => None, + }) + .collect(); + + // Need at least //.bsl to make sense: + // a `.bsl` directly inside / is not a real Hierarchical layout. + for (idx, component) in components.iter().enumerate() { + if !is_metadata_type(component) { + continue; + } + if idx + 2 > components.len() { + // No object name after the metadata-type directory. + continue; + } + let object_name = components[idx + 1]; + // Sanity: the object name must not be a file (e.g. nested BSL right under + // /); skip and keep searching. + if object_name.ends_with(".bsl") || object_name.ends_with(".xml") { + continue; + } + + let mut xml = source_root.to_path_buf(); + let mut dir = source_root.to_path_buf(); + for prefix in &components[..=idx] { + xml.push(prefix); + dir.push(prefix); + } + xml.push(format!("{object_name}.xml")); + dir.push(object_name); + return Some(MetadataOwner { xml, dir }); + } + + None +} + +fn is_metadata_type(name: &str) -> bool { + METADATA_TYPES.iter().any(|known| *known == name) +} + +/// Recursively adds every regular file under `dir` (relative to `source_root`) +/// into `paths`. Unreadable entries are silently skipped — the goal is best +/// effort enumeration of the owning object's tree, not a strict invariant. +fn collect_object_directory( + paths: &mut Vec, + dir: &Path, + source_root: &Path, + root_real: &Path, +) -> Option<()> { + let Ok(entries) = std::fs::read_dir(dir) else { + return Some(()); + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_object_directory(paths, &path, source_root, root_real)?; + } else if path.is_file() { + push_if_safe(paths, &path, source_root, root_real)?; + } + } + Some(()) +} + fn push_if_safe( paths: &mut Vec, candidate: &Path, @@ -174,33 +326,6 @@ fn is_bsl(path: &Path) -> bool { .unwrap_or(false) } -/// Return the XML descriptor alongside a `.bsl` file (same name, `.xml` ext). -fn sibling_xml(bsl: &Path) -> Option { - let parent = bsl.parent()?; - let stem = bsl.file_stem()?.to_str()?; - Some(parent.join(format!("{stem}.xml"))) -} - -/// Return the object directory that owns a `.bsl` module. -fn object_dir(bsl: &Path, source_root: &Path) -> Option { - let parent = bsl.parent()?; - let relative = bsl.strip_prefix(source_root).ok(); - let is_nested_module = bsl - .file_name() - .and_then(|n| n.to_str()) - .map(|name| name.eq_ignore_ascii_case("Module.bsl")) - .unwrap_or(false) - && relative - .map(|path| path.components().count() >= 4) - .unwrap_or(false); - - if is_nested_module { - return parent.parent()?.parent().map(Path::to_path_buf); - } - - Some(parent.to_path_buf()) -} - #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; @@ -208,29 +333,179 @@ mod tests { use tempfile::tempdir; use super::{ - decide, object_dir, relative_paths, write_list_file, LoadDecision, + decide, locate_metadata_object, relative_paths, write_list_file, LoadDecision, DEFAULT_PARTIAL_LOAD_THRESHOLD, }; use crate::change_detection::analyzer::{ChangeKind, FileChange}; + /// Helper: ensure that the file is created on disk so that `canonicalize` in the + /// production code succeeds. Returns the absolute path. + fn touch(root: &Path, relative: &str) -> PathBuf { + let path = root.join(relative); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, b"").expect("write"); + path + } + #[test] - fn object_dir_uses_parent_for_top_level_modules() { - let bsl = Path::new("/tmp/src/Catalogs.Items/ObjectModule.bsl"); + fn locate_metadata_object_for_common_module_designer_layout() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("CommonModules/MyModule/Ext/Module.bsl"); - assert_eq!( - object_dir(bsl, Path::new("/tmp/src")), - Some(PathBuf::from("/tmp/src/Catalogs.Items")) + let owner = locate_metadata_object(&bsl, root).expect("owner"); + + assert_eq!(owner.xml, root.join("CommonModules/MyModule.xml")); + assert_eq!(owner.dir, root.join("CommonModules/MyModule")); + } + + #[test] + fn locate_metadata_object_for_document_object_module() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("Documents/Order/Ext/ObjectModule.bsl"); + + let owner = locate_metadata_object(&bsl, root).expect("owner"); + + assert_eq!(owner.xml, root.join("Documents/Order.xml")); + assert_eq!(owner.dir, root.join("Documents/Order")); + } + + #[test] + fn locate_metadata_object_for_form_module_walks_up_to_document() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("Documents/Order/Forms/MainForm/Ext/Form/Module.bsl"); + + let owner = locate_metadata_object(&bsl, root).expect("owner"); + + // Form's owning object is the document itself — Designer needs the document + // XML, not the form XML, to recognise the target during partial load. + assert_eq!(owner.xml, root.join("Documents/Order.xml")); + assert_eq!(owner.dir, root.join("Documents/Order")); + } + + #[test] + fn locate_metadata_object_returns_none_when_no_metadata_type_in_path() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("Misc/something/Module.bsl"); + + assert!(locate_metadata_object(&bsl, root).is_none()); + } + + #[test] + fn decide_partial_load_for_common_module_adds_xml_and_object_dir_recursively() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + + let xml = touch(root, "CommonModules/MyModule.xml"); + let module = touch(root, "CommonModules/MyModule/Ext/Module.bsl"); + + let decision = decide( + &[FileChange { + path: module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, ); + + // Sorted, deduped: the BSL itself (picked up both as the change and via the + // recursive walk of the object directory) + the XML descriptor. PathBuf + // ordering is component-wise, so "MyModule/Ext/Module.bsl" sorts before + // "MyModule.xml" — the shorter `MyModule` component beats `MyModule.xml`. + assert_eq!(decision, LoadDecision::Partial(vec![module, xml])); } #[test] - fn object_dir_uses_owning_object_for_nested_modules() { - let bsl = Path::new("/tmp/src/Catalogs.Items/Forms/Form1/Module.bsl"); + fn decide_partial_load_for_document_object_module_includes_all_object_files() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); - assert_eq!( - object_dir(bsl, Path::new("/tmp/src")), - Some(PathBuf::from("/tmp/src/Catalogs.Items")) + let doc_xml = touch(root, "Documents/Order.xml"); + let object_module = touch(root, "Documents/Order/Ext/ObjectModule.bsl"); + let manager_module = touch(root, "Documents/Order/Ext/ManagerModule.bsl"); + let form_xml = touch(root, "Documents/Order/Forms/MainForm.xml"); + let form_descriptor = touch(root, "Documents/Order/Forms/MainForm/Ext/Form.xml"); + let form_module = touch(root, "Documents/Order/Forms/MainForm/Ext/Form/Module.bsl"); + + let decision = decide( + &[FileChange { + path: object_module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, ); + + let LoadDecision::Partial(mut paths) = decision else { + panic!("expected Partial decision, got {decision:?}"); + }; + paths.sort(); + + let mut expected = vec![ + doc_xml, + object_module, + manager_module, + form_xml, + form_descriptor, + form_module, + ]; + expected.sort(); + + assert_eq!(paths, expected); + } + + #[test] + fn decide_partial_load_for_form_module_pulls_in_owning_document() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + + let doc_xml = touch(root, "Documents/Order.xml"); + let object_module = touch(root, "Documents/Order/Ext/ObjectModule.bsl"); + let form_xml = touch(root, "Documents/Order/Forms/MainForm.xml"); + let form_module = touch(root, "Documents/Order/Forms/MainForm/Ext/Form/Module.bsl"); + + let decision = decide( + &[FileChange { + path: form_module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, + ); + + let LoadDecision::Partial(mut paths) = decision else { + panic!("expected Partial decision, got {decision:?}"); + }; + paths.sort(); + + // Editing one form module must still bring the document XML and sibling + // files (other modules, other forms) along so Designer can load the + // whole object coherently. + let mut expected = vec![doc_xml, object_module, form_xml, form_module]; + expected.sort(); + + assert_eq!(paths, expected); + } + + #[test] + fn decide_bsl_outside_metadata_whitelist_adds_only_the_file() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let module = touch(root, "Misc/SomeFolder/Module.bsl"); + + let decision = decide( + &[FileChange { + path: module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, + ); + + assert_eq!(decision, LoadDecision::Partial(vec![module])); } #[test] @@ -248,14 +523,14 @@ mod tests { fn relative_paths_returns_relative_entries_for_safe_paths() { let temp = tempdir().expect("tempdir"); let root = temp.path(); - let nested = root.join("Catalogs.Items"); - std::fs::create_dir_all(&nested).expect("mkdir"); - let file = nested.join("ObjectModule.bsl"); - std::fs::write(&file, "module").expect("write"); + let module = touch(root, "CommonModules/MyModule/Ext/Module.bsl"); - let rels = relative_paths(&[file.clone()], root).expect("relative paths"); + let rels = relative_paths(&[module.clone()], root).expect("relative paths"); - assert_eq!(rels, vec![PathBuf::from("Catalogs.Items/ObjectModule.bsl")]); + assert_eq!( + rels, + vec![PathBuf::from("CommonModules/MyModule/Ext/Module.bsl")] + ); } #[cfg(unix)] @@ -266,16 +541,15 @@ mod tests { let temp = tempdir().expect("tempdir"); let root = temp.path().join("src"); let outside = temp.path().join("outside"); - let link = root.join("Catalogs.Items"); - let escaped = outside.join("ObjectModule.bsl"); + let link = root.join("CommonModules"); + let escaped = outside.join("Module.bsl"); std::fs::create_dir_all(&root).expect("root"); std::fs::create_dir_all(&outside).expect("outside"); std::fs::write(&escaped, "module").expect("escaped"); symlink(&outside, &link).expect("link"); - let err = - relative_paths(&[link.join("ObjectModule.bsl")], &root).expect_err("expected error"); + let err = relative_paths(&[link.join("Module.bsl")], &root).expect_err("expected error"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } @@ -288,8 +562,8 @@ mod tests { let temp = tempdir().expect("tempdir"); let root = temp.path().join("src"); let outside = temp.path().join("outside"); - let link = root.join("Catalogs.Items"); - let escaped = outside.join("ObjectModule.bsl"); + let link = root.join("CommonModules"); + let escaped = outside.join("Module.bsl"); let list_file = temp.path().join("partial.lst"); std::fs::create_dir_all(&root).expect("root"); @@ -297,45 +571,17 @@ mod tests { std::fs::write(&escaped, "module").expect("escaped"); symlink(&outside, &link).expect("link"); - let err = write_list_file(&[link.join("ObjectModule.bsl")], &root, &list_file) + let err = write_list_file(&[link.join("Module.bsl")], &root, &list_file) .expect_err("expected invalid path"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } - #[test] - fn decide_expands_bsl_to_xml_and_object_dir() { - let temp = tempdir().expect("tempdir"); - let root = temp.path(); - let object_dir = root.join("Catalogs.Items"); - let module = object_dir.join("ObjectModule.bsl"); - let xml = object_dir.join("ObjectModule.xml"); - - std::fs::create_dir_all(&object_dir).expect("create object dir"); - std::fs::write(&module, "module").expect("write module"); - std::fs::write(&xml, "").expect("write xml"); - - let decision = decide( - &[FileChange { - path: module.clone(), - kind: ChangeKind::Modified, - }], - root, - DEFAULT_PARTIAL_LOAD_THRESHOLD, - ); - - assert_eq!( - decision, - LoadDecision::Partial(vec![object_dir, module, xml]) - ); - } - #[test] fn decide_forces_full_when_configuration_xml_changed() { let temp = tempdir().expect("tempdir"); let root = temp.path(); - let config_xml = root.join("Configuration.xml"); - std::fs::write(&config_xml, "").expect("write config"); + let config_xml = touch(root, "Configuration.xml"); let decision = decide( &[FileChange { @@ -353,7 +599,9 @@ mod tests { fn decide_forces_full_when_deleted_files_exist() { let temp = tempdir().expect("tempdir"); let root = temp.path(); - let removed = root.join("Catalogs.Items").join("ObjectModule.bsl"); + // Even though the path itself does not need to exist for a deletion event, + // the Hierarchical layout is preserved for readability. + let removed = root.join("CommonModules/MyModule/Ext/Module.bsl"); let decision = decide( &[FileChange { @@ -373,10 +621,14 @@ mod tests { let root = temp.path(); let mut changes = Vec::new(); + // Each module sits in its own object directory but without an XML descriptor + // on disk — that keeps the per-change file count to exactly one and lets us + // count past the threshold predictably. for index in 0..=DEFAULT_PARTIAL_LOAD_THRESHOLD { - let path = root.join(format!("CommonModules/Module{index}.bsl")); - std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); - std::fs::write(&path, "module").expect("write"); + let path = touch( + root, + &format!("CommonModules/Module{index}/Ext/Module.bsl"), + ); changes.push(FileChange { path, kind: ChangeKind::Modified, @@ -395,8 +647,8 @@ mod tests { let temp = tempdir().expect("tempdir"); let root = temp.path().join("src"); let outside_dir = temp.path().join("outside"); - let link_dir = root.join("Catalogs.Items"); - let escaped = outside_dir.join("ObjectModule.bsl"); + let link_dir = root.join("CommonModules"); + let escaped = outside_dir.join("Module.bsl"); std::fs::create_dir_all(&outside_dir).expect("outside"); std::fs::create_dir_all(&root).expect("root"); @@ -405,7 +657,7 @@ mod tests { let decision = decide( &[FileChange { - path: link_dir.join("ObjectModule.bsl"), + path: link_dir.join("Module.bsl"), kind: ChangeKind::Modified, }], &root,