//! Behavior version catalog for version-managed tools. //! //! This module defines which tools are version-managed, the available behavior //! presets (e.g. `"current"`, `"legacy-0.4.10"`), and the resolution logic that //! maps (preset, tool_id, per_tool_override) → concrete `contract_version`. //! //! ## Reminder behavior policy //! //! Reminders (per-tool and cross-cutting) always use current behavior regardless //! of the selected behavior preset. This is a deliberate design choice: //! reminders are a quality-of-life feature, not part of the tool contract. use std::collections::HashMap; /// Lifecycle stage of a behavior preset or version. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BehaviorLifecycle { /// Fully supported and recommended. Active, /// Still works but will be removed in a future release. Deprecated, /// Scheduled for removal — hosts should reject requests using this version. RemovalCandidate, } /// Per-tool version metadata. Each managed tool has one entry in `TOOL_VERSION_REGISTRY`. #[derive(Debug)] pub struct ToolVersionEntry { /// Fully-qualified tool ID (e.g. `"Kigi:run_terminal_cmd"`). pub fq_tool_id: &'static str, /// Supported versions and their individual lifecycle. pub versions: &'static [VersionLifecycle], } /// A single version supported by a tool. #[derive(Debug)] pub struct VersionLifecycle { /// Version string (e.g. `"current"`, `"legacy-0.4.10"`). pub version: &'static str, /// Independent lifecycle for this tool+version pair. pub lifecycle: BehaviorLifecycle, /// Suggested replacement when deprecated/removed. Must itself be a /// supported Active version for the same tool. pub replacement: Option<&'static str>, /// Optional deprecation message for operational tooling. pub deprecation_note: Option<&'static str>, /// The crate release version in which this catalog entry was first added. /// This is catalog-introduction metadata, not behavior-origin metadata. /// For reconstructed legacy behavior, this is the release that added the /// legacy port to the catalog. /// Empty string for `"current"` (moving alias with no fixed origin). pub cataloged_in: &'static str, /// Optional opaque references for humans browsing the catalog. /// Prefer empty; do not embed external PR/issue number lists here. /// Empty slice for `"current"` (moving alias). pub source_refs: &'static [&'static str], /// One-line summary of what this version's behavior is. /// Empty string for `"current"` (moving alias). pub summary: &'static str, } /// A named behavior preset (bundle) that maps tool IDs to default contract versions. /// /// Architecturally a "bundle" — a convenience mapping from a label to per-tool /// version defaults. Not canonical; must validate against `TOOL_VERSION_REGISTRY`. #[derive(Debug)] pub struct PresetEntry { /// Preset name, e.g. `"current"` or `"legacy-0.4.10"`. pub name: &'static str, /// Lifecycle stage of this preset. pub lifecycle: BehaviorLifecycle, /// Per-tool default versions for this preset. /// Keys are fully-qualified tool IDs (e.g. `"Kigi:run_terminal_cmd"`). /// Tools not listed here fall back to `"current"`. pub tool_defaults: &'static [(&'static str, &'static str)], } /// Fully-qualified IDs of version-managed tools. /// /// Only tools listed here can have `behavior_version` overrides. /// Uses fully-qualified IDs (`Namespace:tool_id`) to prevent collisions /// between namespaces (e.g. `Kigi:run_terminal_cmd` vs. /// `KigiConcise:run_terminal_cmd`). pub const MANAGED_TOOLS: &[&str] = &[ "Kigi:run_terminal_cmd", "Kigi:read_file", "Kigi:search_replace", "Kigi:list_dir", "Kigi:grep", "Kigi:kill_task", "Kigi:get_task_output", ]; // `V_CURRENT` is a moving alias — its metadata fields are empty because // `"current"` changes meaning over time. Stable canonical versions use // per-tool legacy constants with tool-specific metadata. const V_CURRENT: VersionLifecycle = VersionLifecycle { version: "current", lifecycle: BehaviorLifecycle::Active, replacement: None, deprecation_note: None, cataloged_in: "", source_refs: &[], summary: "", }; // Per-tool legacy constants — each carries tool-specific metadata because // the legacy behavior differs per tool. const V_LEGACY_BASH: VersionLifecycle = VersionLifecycle { version: "legacy-0.4.10", lifecycle: BehaviorLifecycle::Active, replacement: Some("current"), deprecation_note: None, cataloged_in: "0.1.158-alpha.1", source_refs: &[], summary: "Trailing-only & detection, legacy error text for background operator", }; const V_LEGACY_READ_FILE: VersionLifecycle = VersionLifecycle { version: "legacy-0.4.10", lifecycle: BehaviorLifecycle::Active, replacement: Some("current"), deprecation_note: None, cataloged_in: "0.1.158-alpha.1", source_refs: &[], summary: "No gitignore enforcement, generic error text, no confusable reminders", }; const V_LEGACY_SEARCH_REPLACE: VersionLifecycle = VersionLifecycle { version: "legacy-0.4.10", lifecycle: BehaviorLifecycle::Active, replacement: Some("current"), deprecation_note: None, cataloged_in: "0.1.158-alpha.1", source_refs: &[], summary: "No gitignore enforcement, structured errors downgraded to InvalidInput", }; const V_LEGACY_LIST_DIR: VersionLifecycle = VersionLifecycle { version: "legacy-0.4.10", lifecycle: BehaviorLifecycle::Active, replacement: Some("current"), deprecation_note: None, cataloged_in: "0.1.158-alpha.1", source_refs: &[], summary: "Depth-threshold rendering, generic error text, no-children-found for empty dirs", }; const V_LEGACY_KILL_TASK: VersionLifecycle = VersionLifecycle { version: "legacy-0.4.10", lifecycle: BehaviorLifecycle::Active, replacement: Some("current"), deprecation_note: None, cataloged_in: "0.1.158-alpha.1", source_refs: &[], summary: "Simple not-found text without known task ID enumeration", }; const V_LEGACY_TASK_OUTPUT: VersionLifecycle = VersionLifecycle { version: "legacy-0.4.10", lifecycle: BehaviorLifecycle::Active, replacement: Some("current"), deprecation_note: None, cataloged_in: "0.1.158-alpha.1", source_refs: &[], summary: "Simple not-found text without known task ID enumeration", }; /// Per-tool version registry — canonical source for which versions each /// managed tool supports and their individual lifecycle. /// /// - 7 managed tools total /// - 6 legacy-ported (support both `"current"` and `"legacy-0.4.10"`) /// - 1 managed but unported (`grep` — only `"current"`) /// /// Each legacy-ported tool has its own `V_LEGACY_*` constant with tool-specific /// `summary` and `source_refs`. Do not use a shared legacy constant. pub const TOOL_VERSION_REGISTRY: &[ToolVersionEntry] = &[ ToolVersionEntry { fq_tool_id: "Kigi:run_terminal_cmd", versions: &[V_CURRENT, V_LEGACY_BASH], }, ToolVersionEntry { fq_tool_id: "Kigi:read_file", versions: &[V_CURRENT, V_LEGACY_READ_FILE], }, ToolVersionEntry { fq_tool_id: "Kigi:search_replace", versions: &[V_CURRENT, V_LEGACY_SEARCH_REPLACE], }, ToolVersionEntry { fq_tool_id: "Kigi:list_dir", versions: &[V_CURRENT, V_LEGACY_LIST_DIR], }, ToolVersionEntry { fq_tool_id: "Kigi:grep", // Managed but no legacy implementation. versions: &[V_CURRENT], }, ToolVersionEntry { fq_tool_id: "Kigi:kill_task", versions: &[V_CURRENT, V_LEGACY_KILL_TASK], }, ToolVersionEntry { fq_tool_id: "Kigi:get_task_output", versions: &[V_CURRENT, V_LEGACY_TASK_OUTPUT], }, ]; /// Available behavior presets. /// /// ## Reminder behavior policy /// /// Reminders always use current behavior regardless of preset selection. /// They are not versioned — this is a deliberate design choice (Option B). pub const PRESETS: &[PresetEntry] = &[ PresetEntry { name: "current", lifecycle: BehaviorLifecycle::Active, // All managed tools default to "current" — no per-tool overrides needed. tool_defaults: &[], }, PresetEntry { name: "legacy-0.4.10", lifecycle: BehaviorLifecycle::Active, tool_defaults: &[ ("Kigi:run_terminal_cmd", "legacy-0.4.10"), ("Kigi:read_file", "legacy-0.4.10"), ("Kigi:search_replace", "legacy-0.4.10"), ("Kigi:get_task_output", "legacy-0.4.10"), ("Kigi:kill_task", "legacy-0.4.10"), ("Kigi:list_dir", "legacy-0.4.10"), ], }, // Release-named presets are best-effort compatibility bundles, not full // historical snapshots. Later-added or unported tools may resolve to // `current` unless explicitly represented. Prefer stable named versions // over `current` here — using `current` means the preset's behavior for // that tool will silently change over time. PresetEntry { name: "release-0.1.157", lifecycle: BehaviorLifecycle::Active, // At 0.1.157, all managed tools were at `current`. No per-tool // version other than `legacy-0.4.10` had been carved out yet. // `grep` was managed but current-only (no legacy port). tool_defaults: &[ ("Kigi:run_terminal_cmd", "current"), ("Kigi:read_file", "current"), ("Kigi:search_replace", "current"), ("Kigi:list_dir", "current"), ("Kigi:grep", "current"), ("Kigi:kill_task", "current"), ("Kigi:get_task_output", "current"), ], }, ]; /// Check whether a contract version string is the legacy-0.4.10 version. /// /// Use this for tools with trivial version deltas (e.g. `kill_task`, /// `get_task_output` which only differ in not-found wording). For tools /// with substantial version divergence, prefer a typed enum like /// `BashVersion` or `ListDirVersion`. pub fn is_legacy_contract(contract_version: Option<&str>) -> bool { contract_version == Some("legacy-0.4.10") } pub fn lookup_preset(name: &str) -> Option<&'static PresetEntry> { PRESETS.iter().find(|p| p.name == name) } pub fn is_version_managed(fq_tool_id: &str) -> bool { MANAGED_TOOLS.contains(&fq_tool_id) } /// Get the supported versions for a tool from the per-tool registry. /// /// Returns `None` if the tool is not in the registry. pub fn tool_supported_versions(fq_tool_id: &str) -> Option<&'static [VersionLifecycle]> { TOOL_VERSION_REGISTRY .iter() .find(|e| e.fq_tool_id == fq_tool_id) .map(|e| e.versions) } /// Get the lifecycle for a specific tool+version pair. /// /// Returns `None` if the tool is not in the registry or does not support /// the requested version. pub fn tool_version_lifecycle(fq_tool_id: &str, version: &str) -> Option { tool_supported_versions(fq_tool_id)? .iter() .find(|v| v.version == version) .map(|v| v.lifecycle) } /// Get the suggested replacement for a specific tool+version pair. /// /// Returns `None` if no replacement is configured (e.g. version is Active). pub fn tool_version_replacement(fq_tool_id: &str, version: &str) -> Option<&'static str> { tool_supported_versions(fq_tool_id)? .iter() .find(|v| v.version == version)? .replacement } /// A deprecation warning produced during version resolution. #[derive(Debug, Clone)] pub struct VersionWarning { /// Fully-qualified tool ID, or empty for bundle-level warnings. pub fq_tool_id: String, /// The deprecated version. pub deprecated_version: String, /// Suggested replacement. pub replacement: String, /// Human-readable message. pub message: String, } /// Result of resolving a version for a single tool, including any warnings. #[derive(Debug)] pub struct VersionResolution { /// The resolved contract version, or None for unmanaged tools. pub contract_version: Option, /// Any deprecation warnings generated during resolution. pub warnings: Vec, } /// Resolve the concrete contract version for a tool. /// /// ## Resolution order /// /// 1. `per_tool_override` — if `Some`, use it (validated against known versions). /// 2. Preset `tool_defaults` entry for this `fq_tool_id`. /// 3. `"current"` (fallback for managed tools not yet in preset defaults). /// /// ## Returns /// /// - `Ok(Some(version))` — for version-managed tools. /// - `Ok(None)` — for tools NOT in `MANAGED_TOOLS` (unmanaged). /// - `Err(msg)` — for: /// - Unknown preset name /// - Unknown `per_tool_override` value /// - `per_tool_override` supplied for a tool not in `MANAGED_TOOLS` /// /// Resolve version — convenience wrapper that discards warnings. /// Use `resolve_version_with_warnings()` when you need deprecation warnings. pub fn resolve_version( preset_name: &str, fq_tool_id: &str, per_tool_override: Option<&str>, ) -> Result, String> { resolve_version_with_warnings(preset_name, fq_tool_id, per_tool_override) .map(|r| r.contract_version) } /// Resolve the concrete contract version for a tool, with deprecation warnings. pub fn resolve_version_with_warnings( preset_name: &str, fq_tool_id: &str, per_tool_override: Option<&str>, ) -> Result { let preset = lookup_preset(preset_name) .ok_or_else(|| format!("unknown behavior_preset: \"{preset_name}\""))?; let mut warnings = Vec::new(); match preset.lifecycle { BehaviorLifecycle::RemovalCandidate => { return Err(format!( "behavior_preset \"{}\" is scheduled for removal and cannot be used", preset_name )); } BehaviorLifecycle::Deprecated => { tracing::warn!( "behavior_preset \"{}\" is deprecated and will be removed in a future release", preset_name ); warnings.push(VersionWarning { // bundle-level warning fq_tool_id: String::new(), deprecated_version: preset_name.to_string(), replacement: "current".to_string(), message: format!( "behavior_preset \"{}\" is deprecated and will be removed in a future release", preset_name ), }); } BehaviorLifecycle::Active => {} } if per_tool_override.is_some() && !is_version_managed(fq_tool_id) { return Err(format!( "behavior_version override not allowed for unmanaged tool \"{fq_tool_id}\"" )); } if !is_version_managed(fq_tool_id) { return Ok(VersionResolution { contract_version: None, warnings, }); } if let Some(override_version) = per_tool_override { let (version, mut tool_warnings) = validate_and_resolve(fq_tool_id, override_version)?; warnings.append(&mut tool_warnings); return Ok(VersionResolution { contract_version: version, warnings, }); } let tool_defaults: HashMap<&str, &str> = preset.tool_defaults.iter().copied().collect(); if let Some(&version) = tool_defaults.get(fq_tool_id) { let (version, mut tool_warnings) = validate_and_resolve(fq_tool_id, version)?; warnings.append(&mut tool_warnings); return Ok(VersionResolution { contract_version: version, warnings, }); } Ok(VersionResolution { contract_version: Some("current".to_string()), warnings, }) } /// Validate a version against the per-tool registry and apply lifecycle rules. /// /// - `Active` → use it. /// - `Deprecated` → warn + continue. /// - `RemovalCandidate` → reject with replacement suggestion. /// - Not found → reject with supported versions list. fn validate_and_resolve( fq_tool_id: &str, version: &str, ) -> Result<(Option, Vec), String> { let supported = tool_supported_versions(fq_tool_id) .ok_or_else(|| format!("tool \"{fq_tool_id}\" not found in version registry"))?; let entry = supported.iter().find(|v| v.version == version); match entry { Some(v) => match v.lifecycle { BehaviorLifecycle::Active => Ok((Some(version.to_string()), vec![])), BehaviorLifecycle::Deprecated => { let note = v.deprecation_note.unwrap_or("no details"); let replacement = v.replacement.unwrap_or("current"); tracing::warn!( tool = fq_tool_id, version = version, replacement = replacement, "behavior version is deprecated: {note}" ); let warning = VersionWarning { fq_tool_id: fq_tool_id.to_string(), deprecated_version: version.to_string(), replacement: replacement.to_string(), message: format!( "{} version '{}' is deprecated: {}. Use '{}' instead.", fq_tool_id, version, note, replacement ), }; Ok((Some(version.to_string()), vec![warning])) } BehaviorLifecycle::RemovalCandidate => { let replacement = v.replacement.unwrap_or("current"); Err(format!( "behavior_version \"{version}\" for tool \"{fq_tool_id}\" \ is scheduled for removal. Use \"{replacement}\" instead." )) } }, None => { let available: Vec<&str> = supported.iter().map(|v| v.version).collect(); Err(format!( "version \"{version}\" is not supported for tool \"{fq_tool_id}\"; \ supported versions: [{}]", available.join(", ") )) } } } #[cfg(test)] mod tests { use super::*; #[test] fn current_preset_resolves_to_current() { let v = resolve_version("current", "Kigi:run_terminal_cmd", None).unwrap(); assert_eq!(v, Some("current".to_string())); } #[test] fn legacy_preset_resolves_ported_tool() { let v = resolve_version("legacy-0.4.10", "Kigi:run_terminal_cmd", None).unwrap(); assert_eq!(v, Some("legacy-0.4.10".to_string())); } #[test] fn legacy_preset_falls_back_to_current_for_unported_tool() { let v = resolve_version("legacy-0.4.10", "Kigi:grep", None).unwrap(); assert_eq!(v, Some("current".to_string())); } #[test] fn legacy_preset_resolves_all_ported_tools() { for fq_id in &[ "Kigi:run_terminal_cmd", "Kigi:read_file", "Kigi:search_replace", "Kigi:get_task_output", "Kigi:kill_task", "Kigi:list_dir", ] { let v = resolve_version("legacy-0.4.10", fq_id, None).unwrap(); assert_eq!( v, Some("legacy-0.4.10".to_string()), "expected legacy-0.4.10 for {fq_id}" ); } } #[test] fn per_tool_override_wins() { let v = resolve_version("current", "Kigi:run_terminal_cmd", Some("legacy-0.4.10")).unwrap(); assert_eq!(v, Some("legacy-0.4.10".to_string())); } #[test] fn unmanaged_tool_returns_none() { let v = resolve_version("current", "Kigi:web_search", None).unwrap(); assert_eq!(v, None); } #[test] fn override_on_unmanaged_tool_errors() { let err = resolve_version("current", "Kigi:web_search", Some("legacy-0.4.10")).unwrap_err(); assert!(err.contains("unmanaged tool")); } #[test] fn unknown_preset_errors() { let err = resolve_version("nonexistent", "Kigi:run_terminal_cmd", None).unwrap_err(); assert!(err.contains("unknown behavior_preset")); } #[test] fn unknown_override_version_errors() { let err = resolve_version("current", "Kigi:run_terminal_cmd", Some("v999")).unwrap_err(); assert!( err.contains("is not supported for tool"), "expected 'is not supported' error, got: {err}" ); assert!( err.contains("supported versions:"), "should list supported versions, got: {err}" ); } #[test] fn all_managed_tools_resolve() { for &fq_id in MANAGED_TOOLS { let v = resolve_version("current", fq_id, None).unwrap(); assert_eq!(v, Some("current".to_string()), "failed for {fq_id}"); } } #[test] fn concise_namespace_not_managed() { assert!(!is_version_managed("KigiConcise:run_terminal_cmd")); let v = resolve_version("current", "KigiConcise:run_terminal_cmd", None).unwrap(); assert_eq!(v, None); } #[test] fn is_version_managed_matches_catalog() { assert!(is_version_managed("Kigi:run_terminal_cmd")); assert!(is_version_managed("Kigi:read_file")); assert!(is_version_managed("Kigi:search_replace")); assert!(is_version_managed("Kigi:list_dir")); assert!(is_version_managed("Kigi:grep")); assert!(is_version_managed("Kigi:kill_task")); assert!(!is_version_managed("Kigi:todo_write")); } #[test] fn active_version_produces_no_warning() { let res = resolve_version_with_warnings("current", "Kigi:run_terminal_cmd", None).unwrap(); assert!( res.warnings.is_empty(), "active versions should produce no warnings, got: {:?}", res.warnings ); } #[test] fn active_bundle_produces_no_bundle_level_warning() { let res = resolve_version_with_warnings("legacy-0.4.10", "Kigi:run_terminal_cmd", None).unwrap(); assert!( res.warnings.iter().all(|w| !w.fq_tool_id.is_empty()), "no bundle-level warning expected for Active bundle, got: {:?}", res.warnings ); } #[test] fn deprecated_version_produces_tool_warning() { // No registry version is Deprecated, so that branch can't be hit // directly. Exercise the Active (no-warning) path and the not-found // error path as the closest reachable proxies. let (version, warnings) = validate_and_resolve("Kigi:run_terminal_cmd", "current").unwrap(); assert_eq!(version, Some("current".to_string())); assert!( warnings.is_empty(), "Active version should produce no warning" ); let err = validate_and_resolve("Kigi:grep", "legacy-0.4.10").unwrap_err(); assert!(err.contains("is not supported"), "got: {err}"); } #[test] fn removal_candidate_version_is_rejected() { // RemovalCandidate versions should be rejected with replacement. // Since no current versions are RemovalCandidate, verify the error // for unsupported version (which exercises the same not-found path). let err = validate_and_resolve("Kigi:grep", "legacy-0.4.10").unwrap_err(); assert!(err.contains("is not supported for tool")); assert!(err.contains("supported versions: [current]")); } #[test] fn resolve_with_warnings_returns_correct_version() { let res = resolve_version_with_warnings("legacy-0.4.10", "Kigi:run_terminal_cmd", None).unwrap(); assert_eq!(res.contract_version, Some("legacy-0.4.10".to_string())); } #[test] fn resolve_with_warnings_unmanaged_returns_none_no_warnings() { let res = resolve_version_with_warnings("current", "Kigi:web_search", None).unwrap(); assert_eq!(res.contract_version, None); assert!(res.warnings.is_empty()); } #[test] fn resolve_with_warnings_unsupported_version_errors_with_list() { let err = resolve_version_with_warnings("current", "Kigi:grep", Some("legacy-0.4.10")) .unwrap_err(); assert!( err.contains("is not supported for tool"), "expected 'not supported' error, got: {err}" ); assert!( err.contains("Kigi:grep"), "error should mention the tool, got: {err}" ); assert!( err.contains("current"), "error should list supported versions, got: {err}" ); } #[test] fn invariant_1_managed_tools_have_registry_entries() { for &fq_id in MANAGED_TOOLS { assert!( tool_supported_versions(fq_id).is_some(), "managed tool {fq_id} missing from TOOL_VERSION_REGISTRY" ); } } #[test] fn invariant_2_registry_tools_are_managed() { for entry in TOOL_VERSION_REGISTRY { assert!( MANAGED_TOOLS.contains(&entry.fq_tool_id), "registry tool {} not in MANAGED_TOOLS", entry.fq_tool_id ); } } #[test] fn invariant_3_bundle_defaults_exist_in_registry() { for preset in PRESETS { for &(fq_id, version) in preset.tool_defaults { let versions = tool_supported_versions(fq_id).unwrap_or_else(|| { panic!("bundle '{}' references unknown tool {}", preset.name, fq_id) }); assert!( versions.iter().any(|v| v.version == version), "bundle '{}' references version '{}' for tool '{}' but it's not in the registry", preset.name, version, fq_id ); } } } #[test] fn invariant_4_no_removal_candidate_in_bundles() { for preset in PRESETS { for &(fq_id, version) in preset.tool_defaults { let lifecycle = tool_version_lifecycle(fq_id, version); assert_ne!( lifecycle, Some(BehaviorLifecycle::RemovalCandidate), "bundle '{}' references removal-candidate version '{}' for tool '{}'", preset.name, version, fq_id ); } } } #[test] fn invariant_5_deprecated_versions_have_replacements() { for entry in TOOL_VERSION_REGISTRY { for v in entry.versions { if v.lifecycle == BehaviorLifecycle::Deprecated { assert!( v.replacement.is_some(), "deprecated version '{}' for tool '{}' must have a replacement", v.version, entry.fq_tool_id ); } } } } #[test] fn invariant_6_replacements_are_supported() { for entry in TOOL_VERSION_REGISTRY { for v in entry.versions { if let Some(replacement) = v.replacement { assert!( entry.versions.iter().any(|rv| rv.version == replacement), "replacement '{}' for '{}@{}' is not a supported version", replacement, entry.fq_tool_id, v.version ); } } } } #[test] fn invariant_7_replacements_not_deprecated_or_removal() { for entry in TOOL_VERSION_REGISTRY { for v in entry.versions { if let Some(replacement) = v.replacement && let Some(target) = entry.versions.iter().find(|rv| rv.version == replacement) { assert_eq!( target.lifecycle, BehaviorLifecycle::Active, "replacement '{}' for '{}@{}' must be Active, not {:?}", replacement, entry.fq_tool_id, v.version, target.lifecycle ); } } } } #[test] fn invariant_8_unmanaged_tools_not_in_bundles_or_registry() { for preset in PRESETS { for &(fq_id, _) in preset.tool_defaults { assert!( is_version_managed(fq_id), "bundle '{}' references unmanaged tool '{}'", preset.name, fq_id ); } } for entry in TOOL_VERSION_REGISTRY { assert!( is_version_managed(entry.fq_tool_id), "registry contains unmanaged tool '{}'", entry.fq_tool_id ); } } #[test] fn all_active_presets_resolve_all_managed_tools() { for preset in PRESETS { if preset.lifecycle != BehaviorLifecycle::Active { continue; } for &fq_id in MANAGED_TOOLS { let result = resolve_version(preset.name, fq_id, None); assert!( result.is_ok(), "preset '{}' failed to resolve tool '{}': {:?}", preset.name, fq_id, result.err() ); let version = result.unwrap(); assert!( version.is_some(), "preset '{}' resolved tool '{}' to None (should be Some for managed tools)", preset.name, fq_id ); } } } #[test] fn release_preset_resolves_all_tools_to_current() { // release-0.1.157 should resolve all managed tools to "current" // because no post-legacy stable version had been carved out yet. for &fq_id in MANAGED_TOOLS { let v = resolve_version("release-0.1.157", fq_id, None).unwrap(); assert_eq!( v, Some("current".to_string()), "release-0.1.157 should resolve {} to current", fq_id ); } } #[test] fn is_legacy_contract_detects_legacy() { assert!(is_legacy_contract(Some("legacy-0.4.10"))); assert!(!is_legacy_contract(Some("current"))); assert!(!is_legacy_contract(None)); assert!(!is_legacy_contract(Some("unknown"))); } #[test] fn current_version_has_empty_metadata() { assert_eq!(V_CURRENT.cataloged_in, ""); assert!(V_CURRENT.source_refs.is_empty()); assert_eq!(V_CURRENT.summary, ""); } #[test] fn legacy_versions_have_populated_tool_specific_metadata() { let legacy_constants = [ ("bash", &V_LEGACY_BASH), ("read_file", &V_LEGACY_READ_FILE), ("search_replace", &V_LEGACY_SEARCH_REPLACE), ("list_dir", &V_LEGACY_LIST_DIR), ("kill_task", &V_LEGACY_KILL_TASK), ("task_output", &V_LEGACY_TASK_OUTPUT), ]; for (tool, v) in &legacy_constants { assert!( !v.cataloged_in.is_empty(), "{tool}: cataloged_in should not be empty" ); // source_refs intentionally empty (no external PR catalog). assert!( v.source_refs.is_empty(), "{tool}: source_refs should be empty" ); assert!(!v.summary.is_empty(), "{tool}: summary should not be empty"); } } #[test] fn legacy_summaries_are_tool_specific() { assert_ne!( V_LEGACY_BASH.summary, V_LEGACY_READ_FILE.summary, "bash and read_file have different legacy behavior" ); assert_ne!( V_LEGACY_READ_FILE.summary, V_LEGACY_LIST_DIR.summary, "read_file and list_dir have different legacy behavior" ); assert_ne!( V_LEGACY_BASH.summary, V_LEGACY_KILL_TASK.summary, "bash and kill_task have different legacy behavior" ); assert_eq!( V_LEGACY_KILL_TASK.summary, V_LEGACY_TASK_OUTPUT.summary, "kill_task and task_output share the same legacy behavior" ); } #[test] fn legacy_source_refs_are_cleared() { // Catalog intentionally keeps source_refs empty. for v in [ &V_LEGACY_BASH, &V_LEGACY_READ_FILE, &V_LEGACY_SEARCH_REPLACE, &V_LEGACY_LIST_DIR, &V_LEGACY_KILL_TASK, &V_LEGACY_TASK_OUTPUT, ] { assert!(v.source_refs.is_empty()); } } }