diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index a83d8755..32b67a96 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -16,6 +16,15 @@ pub struct MissingSourceIssue { pub path: PathBuf, } +/// Checks whether the configured source directory exists and reports the result. +/// +/// # Examples +/// +/// ``` +/// use std::path::Path; +/// +/// assert_eq!(check_source_directory(Path::new(".")), 0); +/// ``` fn check_source_directory(source_dir: &Path) -> usize { if !source_dir.exists() { println!( @@ -34,6 +43,18 @@ fn check_source_directory(source_dir: &Path) -> usize { } } +/// Checks enabled targets for configuration warnings, missing sources, and skills-layout mismatches. +/// +/// Returns the number of issues found and reports each issue to the diagnostic output. +/// +/// # Examples +/// +/// ```ignore +/// use std::path::Path; +/// +/// let issue_count = check_target_sources(&linker, Path::new("src")); +/// assert_eq!(issue_count, 0); +/// ``` fn check_target_sources(linker: &Linker, source_dir: &Path) -> usize { let mut issues = 0; let mut missing_targets = 0; @@ -100,6 +121,16 @@ fn check_target_sources(linker: &Linker, source_dir: &Path) -> usize { issues } +/// Reports duplicate and overlapping destinations configured for enabled targets. +/// +/// Prints each conflict and returns the number of conflicts found. +/// +/// # Examples +/// +/// ```ignore +/// let issue_count = check_destination_conflicts(&linker); +/// assert_eq!(issue_count, 0); +/// ``` fn check_destination_conflicts(linker: &Linker) -> usize { let mut issues = 0; let mut destinations: Vec<(String, String, String)> = Vec::new(); @@ -138,6 +169,20 @@ fn check_destination_conflicts(linker: &Linker) -> usize { issues } +/// Audits enabled MCP servers and reports commands that cannot be found. +/// +/// Disabled servers and servers without configured commands are skipped from +/// command validation. +/// +/// # Examples +/// +/// ```rust,ignore +/// let issue_count = check_mcp_servers(&linker); +/// assert_eq!(issue_count, 0); +/// ``` +/// +/// Returns the number of enabled MCP servers whose configured commands cannot +/// be found. fn check_mcp_servers(linker: &Linker) -> usize { let mut issues = 0; if !linker.config().mcp.enabled { @@ -175,6 +220,15 @@ fn check_mcp_servers(linker: &Linker) -> usize { issues } +/// Checks the project's managed `.gitignore` section and reports any issues. +/// +/// # Examples +/// +/// ```ignore +/// let issue_count = check_gitignore(&linker); +/// assert_eq!(issue_count, 0); +/// ``` +fn check_gitignore(linker: &Linker) -> usize { fn check_gitignore(linker: &Linker) -> usize { let gitignore_path = linker.project_root().join(".gitignore"); if !gitignore_path.exists() { @@ -253,6 +307,18 @@ fn check_gitignore(linker: &Linker) -> usize { issues } +/// Checks whether Claude skills are managed by an enabled target and reports a warning when they are not. +/// +/// # Returns +/// +/// The number of issues found: `1` when unmanaged skills are detected, otherwise `0`. +/// +/// # Examples +/// +/// ```ignore +/// let issues = check_unmanaged_skills(&linker); +/// assert!(issues <= 1); +/// ``` fn check_unmanaged_skills(linker: &Linker) -> usize { if let Some(warning) = check_unmanaged_claude_skills(linker.project_root(), linker.config()) { println!(" {} {}", "⚠".yellow(), warning); @@ -262,6 +328,18 @@ fn check_unmanaged_skills(linker: &Linker) -> usize { } } +/// Runs diagnostic checks for the project configuration and reports any issues found. +/// +/// Configuration discovery and parsing failures are reported to the user and do not +/// cause the diagnostic command to return an error. +/// +/// # Examples +/// +/// ``` +/// let project_root = std::env::current_dir()?; +/// run_doctor(project_root)?; +/// # Ok::<(), Box>(()) +/// ``` pub fn run_doctor(project_root: PathBuf) -> Result<()> { println!("{}", "🩺 Running AgentSync Diagnostic...".bold().cyan()); diff --git a/src/commands/skill.rs b/src/commands/skill.rs index f3458549..fdc3dd42 100644 --- a/src/commands/skill.rs +++ b/src/commands/skill.rs @@ -743,6 +743,26 @@ pub fn run_skill(cmd: SkillCommand, project_root: PathBuf) -> Result<()> { } } +/// Runs the skill suggestion command for a project. +/// +/// # Parameters +/// +/// * `args` - Suggestion options, including selection and installation behavior. +/// * `project_root` - Root directory of the project to analyze. +/// +/// # Returns +/// +/// `Ok(())` when suggestion handling completes successfully; otherwise, an error +/// produced while processing the command. +/// +/// # Examples +/// +/// ```no_run +/// # use std::path::PathBuf; +/// # let args: SkillSuggestArgs = todo!(); +/// run_suggest(args, PathBuf::from("."))?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` pub fn run_suggest(args: SkillSuggestArgs, project_root: PathBuf) -> Result<()> { let service = SuggestionService; let result = run_suggest_inner(&args, &project_root, &service); @@ -753,6 +773,16 @@ pub fn run_suggest(args: SkillSuggestArgs, project_root: PathBuf) -> Result<()> } } +/// Generates repository skill recommendations and optionally installs the selected recommendations. +/// +/// When installation is enabled, renders the installation result according to the requested output mode. +/// +/// # Examples +/// +/// ```no_run +/// let result = run_suggest_inner(&args, project_root, service); +/// assert!(result.is_ok()); +/// ``` fn run_suggest_inner( args: &SkillSuggestArgs, project_root: &Path, @@ -791,6 +821,23 @@ fn run_suggest_inner( Ok(()) } +/// Prints a suggestion response as JSON or human-readable output. +/// +/// # Examples +/// +/// ```no_run +/// # fn example(response: &SuggestResponse) -> Result<()> { +/// print_suggest_output(false, response)?; +/// # Ok(()) +/// # } +/// ``` +/// +/// The output format is selected by `json`; human-readable output uses terminal color settings. +/// +/// # Returns +/// +/// `Ok(())` after the response is printed, or an error if JSON serialization fails. +/// fn print_suggest_output(json: bool, response: &SuggestResponse) -> Result<()> { if json { println!("{}", serde_json::to_string(&response.to_json_response())?); @@ -804,6 +851,32 @@ fn print_suggest_output(json: bool, response: &SuggestResponse) -> Result<()> { Ok(()) } +/// Installs recommended skills using the selected output and interaction mode. +/// +/// # Examples +/// +/// ```ignore +/// let result = run_suggest_install( +/// &args, +/// project_root, +/// &service, +/// &response, +/// &provider, +/// output_mode, +/// ); +/// ``` +/// +/// # Errors +/// +/// Returns an error if interactive selection is unavailable or installation fails. +fn run_suggest_install( +args: &SkillSuggestArgs, +project_root: &Path, +service: &SuggestionService, +response: &SuggestResponse, +provider: &SuggestInstallProvider, +output_mode: SuggestInstallOutputMode, +) -> Result { fn run_suggest_install( args: &SkillSuggestArgs, project_root: &Path, @@ -855,6 +928,19 @@ fn run_suggest_install( } } +/// Resolves the installation mode and skill IDs for recommended skills. +/// +/// Selects all recommendations when `--all` is enabled. Otherwise, requires +/// interactive installation support and obtains the selected skill IDs from the +/// user. +/// +/// # Examples +/// +/// ```ignore +/// let (mode, skill_ids) = resolve_install_mode_and_ids(&args, &response)?; +/// assert!(!skill_ids.is_empty()); +/// # Ok::<(), anyhow::Error>(()) +/// ``` fn resolve_install_mode_and_ids( args: &SkillSuggestArgs, response: &SuggestResponse, @@ -877,6 +963,24 @@ fn resolve_install_mode_and_ids( } } +/// Installs the selected skill recommendations and reports progress as human-readable lines. +/// +/// # Examples +/// +/// ```rust,ignore +/// let result = run_suggest_install_human_line( +/// &args, +/// project_root, +/// &service, +/// &response, +/// &provider, +/// use_color, +/// ); +/// ``` +/// +/// # Returns +/// +/// The structured installation result, or an error if the installation mode is invalid or installation fails. fn run_suggest_install_human_line( args: &SkillSuggestArgs, project_root: &Path, @@ -906,6 +1010,23 @@ fn run_suggest_install_human_line( ) } +/// Installs the selected skill recommendations while displaying live progress. +/// +/// # Examples +/// +/// ```ignore +/// let result = run_suggest_install_human_live( +/// &args, +/// project_root, +/// &service, +/// &response, +/// &provider, +/// true, +/// ); +/// assert!(result.is_ok()); +/// ``` +/// +/// Returns the installation response or an error if the selection or installation fails. fn run_suggest_install_human_live( args: &SkillSuggestArgs, project_root: &Path, @@ -937,6 +1058,19 @@ fn run_suggest_install_human_live( result } +/// Formats a suggestion command error for human or JSON output and preserves the original error. +/// +/// # Examples +/// +/// ```no_run +/// let args = SkillSuggestArgs::default(); +/// let result = handle_suggest_error(&args, anyhow::anyhow!("suggestion failed")); +/// assert!(result.is_err()); +/// ``` +/// +/// # Returns +/// +/// The original error wrapped in `Err` after the formatted message is emitted. fn handle_suggest_error(args: &SkillSuggestArgs, error: anyhow::Error) -> Result<()> { let error_message = error.to_string(); let (code, remediation) = if error_message @@ -981,6 +1115,18 @@ fn handle_suggest_error(args: &SkillSuggestArgs, error: anyhow::Error) -> Result Err(error) } +/// Verifies that guided recommendation installation is running with interactive standard input and output. +/// +/// # Errors +/// +/// Returns an error when either standard input or standard output is not connected to a terminal. +/// +/// # Examples +/// +/// ``` +/// let result = ensure_interactive_install_supported(); +/// assert!(result.is_ok() || result.is_err()); +/// ``` fn ensure_interactive_install_supported() -> Result<()> { if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() { return Ok(()); @@ -1298,21 +1444,23 @@ fn infer_install_source_format(source: &str) -> String { "dir".to_string() } -/// Attempts to convert a GitHub URL to a downloadable ZIP URL. +/// Converts supported GitHub repository URLs to downloadable ZIP URLs. /// -/// Supports the following GitHub URL formats: -/// - `https://github.com/owner/repo` β†’ `https://github.com/owner/repo/archive/HEAD.zip` -/// - `https://github.com/owner/repo/tree/branch/path` β†’ `https://github.com/owner/repo/archive/refs/heads/branch.zip#path` -/// - `https://github.com/owner/repo/blob/branch/path/file` β†’ `https://github.com/owner/repo/archive/refs/heads/branch.zip#path` +/// Repository URLs use the `HEAD` archive; `tree` and `blob` URLs use the +/// identified branch and preserve the remaining path as a fragment. Branch +/// names containing slashes are interpreted using only their first segment. /// -/// **Limitation:** Branch names containing slashes (e.g., `feature/auth`) cannot be reliably -/// distinguished from subpaths without accessing the GitHub API. In such cases, the function -/// assumes the first segment after `tree/` or `blob/` is the branch name. For branches with -/// slashes, the resulting URL may be incorrect. If API access becomes available in the future, -/// this function could use the GitHub refs API to resolve the correct branch name via -/// longest-prefix matching. +/// Returns `None` for non-GitHub URLs, existing archive URLs, and unsupported +/// GitHub paths. /// -/// Returns `None` if the URL is not a GitHub URL or already points to an archive. +/// # Examples +/// +/// ``` +/// assert_eq!( +/// try_convert_github_url("https://github.com/owner/repo"), +/// Some("https://github.com/owner/repo/archive/HEAD.zip".to_string()) +/// ); +/// ``` fn try_convert_github_url(url: &str) -> Option { let parsed = url::Url::parse(url).ok()?; @@ -1351,6 +1499,19 @@ fn try_convert_github_url(url: &str) -> Option { None } +/// Converts GitHub tree or blob path segments into a downloadable branch archive URL. +/// +/// # Examples +/// +/// ``` +/// let segments = ["owner", "repo", "tree", "main", "skills"]; +/// let url = convert_github_tree_blob_url("owner", "repo", &segments); +/// +/// assert_eq!( +/// url, +/// Some("https://github.com/owner/repo/archive/refs/heads/main.zip#skills".to_string()) +/// ); +/// ``` fn convert_github_tree_blob_url(owner: &str, repo: &str, segments: &[&str]) -> Option { let branch = segments[3]; let subpath = segments[4..].join("/"); @@ -1374,6 +1535,21 @@ fn convert_github_tree_blob_url(owner: &str, repo: &str, segments: &[&str]) -> O Some(zip_url) } +/// Returns the directory path containing a blob subpath. + +/// + +/// # Examples + +/// + +/// ``` + +/// assert_eq!(resolve_blob_subpath("src/main.rs"), "src"); + +/// assert_eq!(resolve_blob_subpath("README.md"), ""); + +/// ``` fn resolve_blob_subpath(subpath: &str) -> String { if subpath.contains('/') { let path_parts: Vec<&str> = subpath.split('/').collect(); @@ -1387,6 +1563,24 @@ fn resolve_blob_subpath(subpath: &str) -> String { } } +/// Provides remediation guidance based on an error message. +/// +/// # Examples +/// +/// ``` +/// assert_eq!( +/// remediation_for_error("manifest is invalid"), +/// "Check the SKILL.md syntax, frontmatter, and ensure the 'name' field matches requirements. See agentsync docs/spec for manifest schema." +/// ); +/// ``` +/// +/// # Arguments +/// +/// * `msg` - Error message used to select the relevant guidance. +/// +/// # Returns +/// +/// Guidance for resolving the error category, or general troubleshooting advice when no category matches. fn remediation_for_error(msg: &str) -> &str { if msg.contains("manifest") { "Check the SKILL.md syntax, frontmatter, and ensure the 'name' field matches requirements. See agentsync docs/spec for manifest schema." diff --git a/src/commands/status.rs b/src/commands/status.rs index bcbde9fa..09e7f3ec 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -397,6 +397,19 @@ pub(crate) fn render_status_summary(problems: usize, formatter: &HumanFormatter) } } +/// Validates a destination symlink against its expected source and records any detected issues. +/// +/// # Examples +/// +/// ``` +/// let entry = validate_symlink_entry( +/// std::path::PathBuf::from("/tmp/config"), +/// None, +/// SyncType::Symlink, +/// ); +/// +/// assert!(!entry.issues.is_empty()); +/// ``` fn validate_symlink_entry( destination: PathBuf, expected_source: Option, @@ -467,6 +480,27 @@ fn validate_symlink_entry( } } +/// Records any issue found for an expected child entry. +/// +/// # Examples +/// +/// ``` +/// let child_status = StatusChildEntry { +/// path: "target/config".to_owned(), +/// expected_source: "source/config".to_owned(), +/// exists: false, +/// is_symlink: false, +/// points_to: None, +/// }; +/// let mut issues = Vec::new(); +/// +/// collect_child_issues(&child_status, &mut issues); +/// +/// assert!(matches!( +/// issues[0].kind, +/// StatusIssueKind::MissingExpectedChild +/// )); +/// ``` fn collect_child_issues(child_status: &StatusChildEntry, issues: &mut Vec) { if !child_status.exists { issues.push(StatusIssue { @@ -498,6 +532,28 @@ fn collect_child_issues(child_status: &StatusChildEntry, issues: &mut Vec(()) +/// ``` fn validate_symlink_contents_entry( linker: &Linker, destination: PathBuf, diff --git a/src/config.rs b/src/config.rs index 402b532c..96a334ad 100644 --- a/src/config.rs +++ b/src/config.rs @@ -346,8 +346,20 @@ impl Config { config_dir.join(&self.source_dir) } - /// Get all gitignore entries (from config + auto-generated from targets + known patterns) - /// Uses a BTreeSet for efficient deduplication and automatic sorting. + /// Collects configured and automatically generated `.gitignore` entries. + /// + /// Entries are deduplicated and returned in sorted order. Disabled agents are + /// excluded from automatic agent and target entries. + /// + /// # Examples + /// + /// ``` + /// let config = Config::default(); + /// let entries = config.all_gitignore_entries(); + /// + /// assert!(entries.contains(&".agents/skills/*.bak".to_string())); + /// ``` + pub fn all_gitignore_entries(&self) -> Vec pub fn all_gitignore_entries(&self) -> Vec { let mut entries: BTreeSet = self.gitignore.entries.iter().cloned().collect(); @@ -361,6 +373,19 @@ impl Config { entries.into_iter().collect() } + /// Collects the managed `.gitignore` entries contributed by an agent's targets and known patterns. + /// + /// # Examples + /// + /// ``` + /// # use std::collections::BTreeSet; + /// # let mut entries = BTreeSet::new(); + /// # let agent_name = "example"; + /// # // Collect entries for an enabled agent configuration. + /// # let _ = (agent_name, &mut entries); + /// ``` + /// + /// `agent_name` identifies the agent whose known ignore patterns are collected, and `entries` receives the deduplicated results. fn collect_agent_gitignore_entries( agent_name: &str, agent: &AgentConfig, @@ -374,6 +399,19 @@ impl Config { } } + /// Collects the managed Gitignore entries produced by a synchronization target. + /// + /// Nested-glob targets are omitted. Module-map targets contribute entries for each + /// resolved mapped filename and its backup; other targets contribute the destination + /// and its backup. + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut entries = BTreeSet::new(); + /// collect_target_gitignore_entries("agent", &target, &mut entries); + /// assert!(entries.contains("/output/file")); + /// ``` fn collect_target_gitignore_entries( agent_name: &str, target: &TargetConfig, @@ -398,8 +436,22 @@ impl Config { ))); } - /// Get known gitignore patterns for a specific agent. - /// These are files/directories that agents generate but are not direct symlink targets. + /// Returns generated files and directories that should be ignored for an agent. + /// + /// # Arguments + /// + /// * `agent_name` - The agent whose generated files should be identified. + /// + /// # Returns + /// + /// The agent's known ignore patterns. + /// + /// # Examples + /// + /// ``` + /// let patterns = known_ignore_patterns("unknown-agent"); + /// assert!(patterns.is_empty()); + /// ``` pub fn known_ignore_patterns(agent_name: &str) -> &'static [&'static str] { agent_ids::known_ignore_patterns(agent_name) } diff --git a/src/init.rs b/src/init.rs index b0e27154..7f1f40d1 100644 --- a/src/init.rs +++ b/src/init.rs @@ -478,7 +478,21 @@ enum AgentFileType { Other, } -/// Check if a directory has at least one entry, propagating IO errors. +/// Determines whether a directory contains at least one entry. +/// +/// # Errors +/// +/// Returns an error if the directory cannot be read. +/// +/// # Examples +/// +/// ``` +/// # use std::path::Path; +/// # fn example() -> anyhow::Result<()> { +/// let has_entries = dir_has_entries(Path::new("."))?; +/// # Ok(()) +/// # } +/// ``` fn dir_has_entries(path: &Path) -> Result { Ok(fs::read_dir(path) .with_context(|| format!("Failed to read directory: {}", path.display()))? @@ -486,7 +500,26 @@ fn dir_has_entries(path: &Path) -> Result { .is_some()) } -/// Discover a single file and push it if it exists. +/// Adds a discovered file to `discovered` when the relative path exists under `project_root`. +/// +/// # Examples +/// +/// ```rust,ignore +/// let mut discovered = Vec::new(); +/// discover_file( +/// project_root, +/// "AGENTS.md", +/// file_type, +/// "Agent instructions", +/// &mut discovered, +/// ); +/// ``` +/// +/// # Arguments +/// +/// * `project_root` - The directory from which `rel_path` is resolved. +/// * `rel_path` - The file path relative to `project_root`. +/// * `display_name` - The human-readable name stored with the discovered file. fn discover_file( project_root: &Path, rel_path: &str, @@ -521,7 +554,21 @@ fn discover_dir( } } -/// Discover a directory only if it has at least one entry. +/// Records a directory as discovered when it exists and contains at least one entry. +/// +/// # Examples +/// +/// ``` +/// let mut discovered = Vec::new(); +/// discover_dir_with_content( +/// project_root, +/// "skills", +/// AgentFileType::Directory, +/// "Skills", +/// &mut discovered, +/// )?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` fn discover_dir_with_content( project_root: &Path, rel_path: &str, @@ -540,7 +587,22 @@ fn discover_dir_with_content( Ok(()) } -/// Scan for native MCP agent files (Claude, Copilot, Cursor, Gemini, OpenCode, Codex, etc.) +/// Scans the project for native agent instruction files, skill and command directories, and MCP or tooling configuration files. +/// +/// # Examples +/// +/// ``` +/// let root = std::env::temp_dir().join("agentsync-scan-example"); +/// std::fs::create_dir_all(&root)?; +/// std::fs::write(root.join(".mcp.json"), "{}")?; +/// +/// let mut discovered = Vec::new(); +/// scan_native_mcp_agents(&root, &mut discovered)?; +/// assert_eq!(discovered.len(), 1); +/// +/// std::fs::remove_dir_all(root)?; +/// # Ok::<(), Box>(()) +/// ``` fn scan_native_mcp_agents(project_root: &Path, discovered: &mut Vec) -> Result<()> { // Claude Code discover_file( @@ -687,7 +749,17 @@ fn scan_native_mcp_agents(project_root: &Path, discovered: &mut Vec>(()) +/// ``` fn scan_configurable_agents( project_root: &Path, discovered: &mut Vec, @@ -934,7 +1006,21 @@ fn scan_configurable_agents( Ok(()) } -/// Scan project for existing agent-related files +/// Discovers agent-related files in a project. +/// +/// # Examples +/// +/// ``` +/// use std::path::Path; +/// +/// let discovered = scan_agent_files(Path::new("."))?; +/// println!("Found {} agent-related files.", discovered.len()); +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error if scanning the project fails. fn scan_agent_files(project_root: &Path) -> Result> { let mut discovered = Vec::new(); scan_native_mcp_agents(project_root, &mut discovered)?; @@ -1572,6 +1658,28 @@ fn render_experimental_tui_intro_frame(frame: &mut ratatui::Frame<'_>) { frame.render_widget(paragraph, chunks[1]); } +/// Displays the interactive introductory screen for the experimental terminal interface. +/// +/// Press `Enter` to continue or `Esc`/`q` to cancel. The terminal is restored when +/// the screen exits, including when an error occurs. +/// +/// # Examples +/// +/// ```no_run +/// match run_experimental_tui_intro()? { +/// ExperimentalTuiIntroOutcome::Continue => { +/// // Proceed with the experimental interface. +/// } +/// ExperimentalTuiIntroOutcome::Cancelled => { +/// // Return to the standard flow. +/// } +/// } +/// # Ok::<(), anyhow::Error>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error if terminal setup, screen rendering, or input handling fails. fn run_experimental_tui_intro() -> Result { use crossterm::{ event::{self, Event, KeyCode}, @@ -1612,7 +1720,23 @@ fn run_experimental_tui_intro() -> Result { } } -/// Merge multiple instruction files into a single string with section headings. +/// Combines instruction files into content suitable for an `AGENTS.md` file. +/// +/// A single file is returned unchanged. Multiple files are separated by headings +/// and delimiters. The returned count indicates how many files were processed. +/// +/// # Errors +/// +/// Returns an error if an instruction file cannot be read. +/// +/// # Examples +/// +/// ``` +/// let (content, count) = merge_instruction_files(std::path::Path::new("."), &[])?; +/// assert!(content.is_none()); +/// assert_eq!(count, 0); +/// # Ok::<(), anyhow::Error>(()) +/// ``` fn merge_instruction_files( project_root: &Path, instruction_files: &[&DiscoveredFile], @@ -1652,7 +1776,31 @@ fn merge_instruction_files( } } -/// Copy directory entries into a destination, printing progress. Returns (migrated, skipped). +/// Copies the entries in a source directory into a destination directory. +/// +/// Existing destination entries are skipped, while missing source directories produce zero migrated and skipped entries. +/// Returns the number of migrated entries and the number of skipped entries. +/// +/// # Errors +/// +/// Returns an error if reading the source directory or copying an entry fails. +/// +/// # Examples +/// +/// ``` +/// let root = std::env::temp_dir().join(format!("copy-entries-{}", std::process::id())); +/// let source = root.join("source"); +/// let destination = root.join("destination"); +/// +/// std::fs::create_dir_all(&source).unwrap(); +/// std::fs::create_dir_all(&destination).unwrap(); +/// std::fs::write(source.join("item.txt"), "content").unwrap(); +/// +/// let (migrated, skipped) = copy_entries_to_dest(&source, &destination, "file").unwrap(); +/// assert_eq!((migrated, skipped), (1, 0)); +/// +/// std::fs::remove_dir_all(root).unwrap(); +/// ``` fn copy_entries_to_dest( src_path: &Path, dest_dir: &Path, @@ -1705,7 +1853,26 @@ fn copy_entries_to_dest( Ok((migrated, skipped)) } -/// Migrate a single discovered file, returning (migrated_count, skipped_count). +/// Migrates a discovered file or directory to the appropriate `.agents` destination. +/// +/// Instruction files are handled separately and are skipped here. MCP and tooling +/// configuration files are reported without being copied. +/// +/// # Examples +/// +/// ```rust,ignore +/// let (migrated, skipped) = migrate_file( +/// &file, +/// project_root, +/// agents_dir, +/// skills_dir, +/// commands_dir, +/// )?; +/// assert_eq!(migrated + skipped, 1); +/// # Ok::<(), anyhow::Error>(()) +/// ``` +/// +/// Returns the number of migrated and skipped items, respectively. fn migrate_file( file: &DiscoveredFile, project_root: &Path, @@ -1835,7 +2002,21 @@ fn migrate_file( } } -/// Write AGENTS.md with migrated or default content. Returns outcome. +/// Writes `AGENTS.md` using migrated content when available, or the default template otherwise. +/// +/// Existing files are preserved unless `force` is `true`. The managed agent configuration +/// layout is added or updated in the written content. +/// +/// # Examples +/// +/// ``` +/// let path = std::env::temp_dir().join(format!("agentsync-{}.md", std::process::id())); +/// let outcome = write_agents_md(&path, Some("# Instructions".into()), "", 1, true)?; +/// +/// assert!(matches!(outcome, ManagedFileOutcome::Written)); +/// std::fs::remove_file(path)?; +/// # Ok::<(), Box>(()) +/// ``` fn write_agents_md( agents_md_path: &Path, migrated_content: Option, @@ -1879,7 +2060,27 @@ fn write_agents_md( } } -/// Write wizard config and run post-init validation. Returns outcome. +/// Writes the rendered wizard configuration and validates skills modes for the selected agents. +/// +/// Preserves an existing configuration unless `force` is `true`. Reports any skills mode +/// mismatches found during post-initialization validation. +/// +/// # Examples +/// +/// ```ignore +/// let outcome = write_wizard_config( +/// project_root, +/// config_path, +/// rendered_config, +/// &[], +/// false, +/// )?; +/// ``` +/// +/// # Returns +/// +/// `ManagedFileOutcome::Preserved` when an existing configuration is kept, or +/// `ManagedFileOutcome::Written` when the configuration is written successfully. fn write_wizard_config( project_root: &Path, config_path: &Path, @@ -1920,7 +2121,26 @@ fn write_wizard_config( Ok(ManagedFileOutcome::Written) } -/// Back up original files after migration. Returns outcome. +/// Backs up migrated source files under `.agents/backup`. +/// +/// The backup is not offered when `AGENTS.md` was preserved or when the user declines. +/// MCP, tooling, and other non-migrated files are excluded. Canonical symlink-based skill +/// layouts are preserved. Returns the resulting backup status and number of moved files. +/// +/// # Examples +/// +/// ```no_run +/// let outcome = perform_wizard_backup( +/// &mut renderer, +/// project_root, +/// agents_dir, +/// &files_to_migrate, +/// &skills_choices, +/// &skills_modes, +/// &agents_md_outcome, +/// )?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` fn perform_wizard_backup( renderer: &mut impl InitWizardRenderer, project_root: &Path, @@ -2008,6 +2228,24 @@ fn perform_wizard_backup( Ok(BackupOutcome::Completed { moved_count }) } +/// Runs the interactive initialization wizard, optionally migrating discovered agent files into `.agents`. +/// +/// # Arguments +/// +/// * `project_root` - Project directory to scan and initialize. +/// * `force` - Whether existing generated files may be overwritten. +/// * `template_path` - Optional path to the configuration template to use. +/// +/// # Examples +/// +/// ```no_run +/// use std::path::Path; +/// +/// init_wizard(Path::new("."), false, None)?; +/// # Ok::<(), Box>(()) +/// ``` +/// +pub fn init_wizard(project_root: &Path, force: bool, template_path: Option<&Path>) -> Result<()> { pub fn init_wizard(project_root: &Path, force: bool, template_path: Option<&Path>) -> Result<()> { use colored::Colorize; use dialoguer::{Confirm, MultiSelect, Select, theme::ColorfulTheme}; diff --git a/src/linker.rs b/src/linker.rs index 9c1a4388..27a96f21 100644 --- a/src/linker.rs +++ b/src/linker.rs @@ -227,10 +227,19 @@ impl Linker { self.ensure_safe_path(dest, &dest.display()).map(|_| ()) } - /// Re-validate a path before unlinking (remove_file/remove_dir). - /// Unlike revalidate_path, this does NOT canonicalize the final component, - /// allowing safe removal of symlinks that point outside project_root. - /// The symlink entry itself must be within project_root, but its target can be anywhere. + /// Validates a path before unlinking it while allowing symlinks to target locations outside the project root. + /// + /// The path entry itself must remain within the project root. The final component is not canonicalized, so symlink entries can be removed safely. + /// + /// # Examples + /// + /// ``` + /// # use std::path::Path; + /// # fn check(linker: &Linker) -> anyhow::Result<()> { + /// linker.revalidate_unlink_path(Path::new("generated/link"))?; + /// # Ok(()) + /// # } + /// ``` fn revalidate_unlink_path(&self, path: &Path) -> Result<()> { let display_path = path.display().to_string(); @@ -252,7 +261,21 @@ impl Linker { self.validate_relative_unlink_parent(path, &display_path) } - /// Validate an absolute path for unlinking: must be under project_root with valid parent. + /// Validates that an absolute unlink path and its parent remain within the project root. + /// + /// # Examples + /// + /// ``` + /// # use std::path::Path; + /// # let path = Path::new("/project/config.toml"); + /// assert!(path.is_absolute()); + /// ``` + /// + /// # Errors + /// + /// Returns an error if the path or its parent resolves outside the project root, or if the parent cannot be canonicalized. + /// + /// `path` is the absolute path to validate. `display_path` identifies the path in error messages. fn validate_absolute_unlink_path(&self, path: &Path, display_path: &str) -> Result<()> { if !path.starts_with(&self.project_root) { anyhow::bail!("Path is outside project root: {}", display_path); @@ -274,7 +297,23 @@ impl Linker { Ok(()) } - /// Validate that the parent of a relative path is within project_root. + /// Validates that an existing parent of a relative unlink path resolves within the project root. + /// + /// Missing or empty parents are accepted. + /// + /// # Errors + /// + /// Returns an error if the existing parent resolves outside the project root. + /// + /// # Examples + /// + /// ```ignore + /// let result = linker.validate_relative_unlink_parent( + /// std::path::Path::new("config/agent.toml"), + /// "config/agent.toml", + /// ); + /// assert!(result.is_ok()); + /// ``` fn validate_relative_unlink_parent(&self, path: &Path, display_path: &str) -> Result<()> { let Some(parent) = path.parent() else { return Ok(()); @@ -637,7 +676,18 @@ impl Linker { Ok(()) } - /// Create a single symlink + /// Creates or updates a symlink from `dest` to an existing source path. + /// + /// Missing sources are skipped. Existing correct symlinks remain unchanged; mismatched + /// symlinks are replaced, and other existing destinations are backed up. + /// + /// # Examples + /// + /// ``` + /// let result = linker.create_symlink(&source, destination, &options)?; + /// # assert_eq!(result.created, 1); + /// # Ok::<(), anyhow::Error>(()) + /// ``` fn create_symlink( &self, source: &ResolvedSource, @@ -723,7 +773,23 @@ impl Linker { Ok(result) } - /// Handle an existing symlink at the destination. Returns the action taken. + /// Updates an existing symbolic link when it points to a different source. + /// + /// Correct links are left unchanged. Mismatched links are removed and marked for + /// recreation, unless the operation is a dry run. + /// + /// # Examples + /// + /// ```ignore + /// let action = linker.handle_existing_symlink(&destination, &relative_source, &options)?; + /// assert_eq!(action, ExistingSymlinkAction::AlreadyCorrect); + /// # Ok::<(), anyhow::Error>(()) + /// ``` + /// + /// # Returns + /// + /// The action taken: `AlreadyCorrect` for a matching link or `Updated` for a + /// link that was removed because it targeted a different source. fn handle_existing_symlink( &self, dest: &Path, @@ -762,7 +828,16 @@ impl Linker { Ok(ExistingSymlinkAction::Updated) } - /// Back up an existing regular file/directory at the destination. + /// Backs up an existing destination by renaming it to a `.bak` path before replacement. + /// + /// In dry-run mode, reports the planned backup without modifying the filesystem. + /// + /// # Examples + /// + /// ```ignore + /// linker.backup_existing_destination(&destination, &options)?; + /// ``` + fn backup_existing_destination... fn backup_existing_destination(&self, dest: &Path, options: &SyncOptions) -> Result<()> { if options.dry_run { println!( @@ -788,7 +863,35 @@ impl Linker { Ok(()) } - /// Create symlinks for all contents of a directory + /// Creates symlinks in a destination directory for matching entries from a source directory. + /// + /// Missing or invalid source directories are counted as skipped. A destination that resolves + /// to the source directory is also skipped to prevent circular symlinks. + /// + /// # Examples + /// + /// ```ignore + /// let result = linker.create_symlinks_for_contents( + /// source_dir, + /// destination_dir, + /// Some("*.md"), + /// target, + /// options, + /// )?; + /// assert!(result.created >= 0); + /// ``` + /// + /// # Arguments + /// + /// * `source_dir` - Directory whose entries should be linked. + /// * `dest_dir` - Directory in which to create the symlinks. + /// * `pattern` - Optional filename pattern used to filter entries. + /// * `target` - Configuration controlling source resolution. + /// * `options` - Synchronization options controlling execution. + /// + /// # Errors + /// + /// Returns an error if the source directory cannot be read or a filesystem operation fails. fn create_symlinks_for_contents( &self, source_dir: &Path, @@ -1283,7 +1386,16 @@ impl Linker { .ok_or_else(|| anyhow::anyhow!("Cannot calculate relative path")) } - /// Clean all symlinks managed by this configuration + /// Removes managed symlinks for all configured agents and targets. + /// + /// # Examples + /// + /// ```no_run + /// # fn example(linker: &Linker, options: &SyncOptions) -> Result { + /// let result = linker.clean(options)?; + /// # Ok(result) + /// # } + /// ``` pub fn clean(&self, options: &SyncOptions) -> Result { let mut result = SyncResult::default(); @@ -1316,7 +1428,16 @@ impl Linker { Ok(result) } - /// Clean a single symlink target. + /// Removes the configured destination symlink when it exists. + /// + /// Invalid destinations and destinations that are not symlinks are ignored. In dry-run + /// mode, reports the removal without modifying the filesystem. + /// + /// # Examples + /// + /// ```ignore + /// linker.clean_symlink_target(&target_config, &options, &mut result)?; + /// ``` fn clean_symlink_target( &self, target_config: &TargetConfig, @@ -1341,7 +1462,17 @@ impl Linker { Ok(()) } - /// Clean symlink-contents: remove symlinks inside the destination directory. + /// Removes symlinks directly contained in the configured destination directory. + /// + /// In dry-run mode, reports the symlinks that would be removed without changing + /// the filesystem. After removal, attempts to delete the destination directory + /// if it is empty. + /// + /// # Examples + /// + /// ```rust,ignore + /// linker.clean_symlink_contents_target(&target_config, &options, &mut result)?; + /// ``` fn clean_symlink_contents_target( &self, target_config: &TargetConfig, @@ -1380,7 +1511,13 @@ impl Linker { Ok(()) } - /// Clean nested-glob targets: re-discover matched files and remove symlinks. + /// Removes symlinks created by a nested-glob target for currently matched files. + /// + /// # Examples + /// + /// ```ignore + /// linker.clean_nested_glob_target(&target_config, &options, &mut result)?; + /// ``` fn clean_nested_glob_target( &self, target_config: &TargetConfig, @@ -1433,7 +1570,19 @@ impl Linker { Ok(()) } - /// Clean module-map targets: remove symlinks for each mapping. + /// Removes symlinks created for each module mapping. + /// + /// Invalid mapping destinations are skipped. In dry-run mode, reports removals without changing the filesystem. + /// + /// # Examples + /// + /// ``` + /// # use anyhow::Result; + /// # fn example(linker: &Linker, agent_name: &str, target_config: &TargetConfig, options: &SyncOptions, result: &mut SyncResult) -> Result<()> { + /// linker.clean_module_map_target(agent_name, target_config, options, result)?; + /// # Ok(()) + /// # } + /// ``` fn clean_module_map_target( &self, agent_name: &str, @@ -1548,11 +1697,32 @@ fn is_agents_md_path(path: &Path) -> bool { path.file_name() == Some(std::ffi::OsStr::new("AGENTS.md")) } +/// Builds the path for the compressed `AGENTS.md` file alongside the source path. +/// +/// # Examples +/// +/// ``` +/// use std::path::Path; +/// +/// let path = compressed_agents_md_path(Path::new("docs/AGENTS.md")); +/// assert_eq!(path, Path::new("docs/AGENTS.compact.md")); +/// ``` fn compressed_agents_md_path(path: &Path) -> PathBuf { path.with_file_name(COMPRESSED_AGENTS_MD_NAME) } -/// Detect a code fence delimiter (``` or ~~~) at the start of a trimmed line. +/// Identifies a backtick or tilde code-fence delimiter at the beginning of a trimmed line. +/// +/// # Examples +/// +/// ``` +/// assert_eq!(detect_fence_delimiter("```rust"), Some("```")); +/// assert_eq!(detect_fence_delimiter("~~~"), Some("~~~")); +/// assert_eq!(detect_fence_delimiter("text"), None); +/// ``` +/// +/// Returns the complete consecutive delimiter sequence when the line starts with +/// at least three backticks or tildes; otherwise, returns `None`. fn detect_fence_delimiter(trimmed_start: &str) -> Option<&str> { if trimmed_start.starts_with("```") { let len = trimmed_start @@ -1569,7 +1739,15 @@ fn detect_fence_delimiter(trimmed_start: &str) -> Option<&str> { } } -/// Toggle fence state: open a new fence, close a matching one, or leave unchanged. +/// Updates the active fence delimiter when opening or closing a matching fence. +/// +/// # Examples +/// +/// ``` +/// assert_eq!(toggle_fence(None, "```"), Some("```")); +/// assert_eq!(toggle_fence(Some("```"), "```"), None); +/// assert_eq!(toggle_fence(Some("```"), "~~~"), Some("```")); +/// ``` fn toggle_fence<'a>(current: Option<&'a str>, delim: &'a str) -> Option<&'a str> { match current { None => Some(delim), @@ -1578,6 +1756,14 @@ fn toggle_fence<'a>(current: Option<&'a str>, delim: &'a str) -> Option<&'a str> } } +/// Compresses Markdown content by removing trailing whitespace, collapsing blank lines, and normalizing inline whitespace outside fenced code blocks. +/// +/// # Examples +/// +/// ``` +/// let input = "Title \n\n\nText\t \n"; +/// assert_eq!(compress_agents_md_content(input), "Title\n\nText\n"); +/// ``` fn compress_agents_md_content(input: &str) -> String { let mut out = String::with_capacity(input.len()); let mut fence_delim: Option<&str> = None; @@ -1710,10 +1896,16 @@ fn matches_path_glob(path: &str, pattern: &str) -> bool { path_glob_match_iter(path.split('/'), &pattern_parts) } -/// Core path-aware glob matching logic using iterators to avoid allocations. -/// This implementation uses a backtracking algorithm which is more performant than a -/// recursive one, especially for patterns with '**' since it avoids stack overhead. -/// It provides O(N*M) complexity in typical cases. +/// Matches path segments against a glob pattern, supporting `*`, `?`, and `**`. +/// +/// # Examples +/// +/// ``` +/// let path = ["src", "bin", "main.rs"]; +/// let pattern = ["src", "**", "*.rs"]; +/// +/// assert!(path_glob_match_iter(path.iter().copied(), &pattern)); +/// ``` fn path_glob_match_iter<'a, I>(mut path_it: I, pattern: &[&str]) -> bool where I: Iterator + Clone, @@ -1748,8 +1940,28 @@ where } } -/// Process a single path segment against the current pattern state. -/// Returns false if matching definitively fails, true to continue. +/// Advances path-pattern matching for one path segment, including `**` backtracking. +/// +/// # Examples +/// +/// ``` +/// let path = ["src", "main.rs"]; +/// let mut path_it = path.iter().copied(); +/// let pattern = ["src"]; +/// let mut pat_idx = 0; +/// let mut backtrack_path_it = None; +/// let mut backtrack_pat_idx = None; +/// +/// assert!(try_match_segment( +/// "src", +/// &mut path_it, +/// &pattern, +/// &mut pat_idx, +/// &mut backtrack_path_it, +/// &mut backtrack_pat_idx, +/// )); +/// assert_eq!(pat_idx, 1); +/// ``` fn try_match_segment<'a, I>( segment: &str, path_it: &mut I, @@ -1784,6 +1996,16 @@ where true } +/// Creates the backup path for a destination by appending `.bak`. +/// +/// # Examples +/// +/// ``` +/// use std::path::Path; +/// +/// let backup = backup_path_for_destination(Path::new("config.toml")); +/// assert_eq!(backup, Path::new("config.toml.bak")); +/// ``` fn backup_path_for_destination(dest: &Path) -> PathBuf { // Performance: Use OsString::push to avoid string formatting and UTF-8 validation overhead. let mut os_string = dest.as_os_str().to_os_string(); diff --git a/src/main.rs b/src/main.rs index e5b8ca85..cf124b3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -382,6 +382,16 @@ enum Commands { }, } +/// Runs the `agentsync` command-line interface. +/// +/// Initializes logging, starts the update check, parses command-line arguments, +/// and dispatches the selected command. +/// +/// # Examples +/// +/// ```text +/// agentsync status +/// ```#+#+#+#+ fn main() -> Result<()> { // Initialize tracing subscriber for structured logging. Respects RUST_LOG env var. tracing_subscriber::fmt::init(); @@ -438,6 +448,26 @@ fn main() -> Result<()> { Ok(()) } +/// Initializes an agentsync configuration in the selected project directory. +/// +/// Runs either the standard initializer or the interactive wizard, optionally +/// using the experimental terminal interface and a custom configuration +/// template. +/// +/// # Examples +/// +/// ```no_run +/// handle_init(None, false, false, false, None)?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` +/// +/// # Arguments +/// +/// * `path` - Project directory to initialize, or the current directory when omitted. +/// * `force` - Whether to overwrite existing managed files. +/// * `wizard` - Whether to use the interactive configuration wizard. +/// * `experimental_tui` - Whether to use the experimental terminal interface for the wizard. +/// * `template` - Optional path to a custom configuration template. fn handle_init( path: Option, force: bool, @@ -475,6 +505,31 @@ fn handle_init( Ok(()) } +/// Applies the configured agent synchronization, optionally cleaning managed links and updating Gitignore and MCP configuration. +/// +/// # Arguments +/// +/// * `path` - Project directory used to locate the configuration when `config` is not provided. +/// * `config` - Explicit configuration file path. +/// * `clean` - Whether to clean managed links before synchronization. +/// * `dry_run` - Whether to report planned changes without modifying files. +/// * `verbose` - Whether to display additional progress information. +/// * `agents` - Optional list of agents to synchronize. +/// * `no_gitignore` - Whether to skip Gitignore updates. +/// +/// # Examples +/// +/// ```no_run +/// handle_apply( +/// None, +/// Some("agentsync.toml".into()), +/// false, +/// false, +/// false, +/// None, +/// false, +/// ).unwrap(); +/// ``` #[allow(clippy::too_many_arguments)] fn handle_apply( path: Option, @@ -547,6 +602,17 @@ fn handle_apply( Ok(()) } +/// Updates or removes the managed `.gitignore` entries for the project. +/// +/// # Examples +/// +/// ```no_run +/// # fn example(linker: &Linker) -> Result<()> { +/// handle_apply_gitignore(linker, true, true)?; +/// # Ok(()) +/// # } +/// ``` +fn handle_apply_gitignore(linker: &Linker, dry_run: bool, use_color: bool) -> Result<()> fn handle_apply_gitignore(linker: &Linker, dry_run: bool, use_color: bool) -> Result<()> { if linker.config().gitignore.enabled { println!(); @@ -572,6 +638,15 @@ fn handle_apply_gitignore(linker: &Linker, dry_run: bool, use_color: bool) -> Re Ok(()) } +/// Synchronizes configured MCP server configurations and records synchronization failures in the aggregate result. +/// +/// # Examples +/// +/// ```ignore +/// let mut result = SyncResult::default(); +/// handle_apply_mcp(&linker, false, true, None, &mut result)?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` fn handle_apply_mcp( linker: &Linker, dry_run: bool, @@ -599,6 +674,37 @@ fn handle_apply_mcp( Ok(()) } +/// Removes managed symlinks from the project using the specified configuration. +/// +/// # Examples +/// +/// ```no_run +/// use std::path::PathBuf; +/// +/// handle_clean( +/// Some(PathBuf::from(".")), +/// Some(PathBuf::from("agentsync.toml")), +/// true, +/// false, +/// )?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` +/// +/// `path` identifies the project directory used to locate configuration when +/// `config` is not provided. A dry run reports the cleanup without modifying +/// files. +/// +/// # Parameters +/// +/// * `path` - Project directory used for configuration discovery. +/// * `config` - Explicit configuration file path. +/// * `dry_run` - Whether to report cleanup without making changes. +/// * `verbose` - Whether to include verbose cleanup output. +/// +/// # Returns +/// +/// `Ok(())` after the cleanup summary is displayed, or an error if +/// configuration loading or cleanup fails. fn handle_clean( path: Option, config: Option, @@ -632,6 +738,13 @@ fn handle_clean( Ok(()) } +/// Prints the application banner with emphasized terminal styling. +/// +/// # Examples +/// +/// ``` +/// print_header(); +/// ``` fn print_header() { let banner = include_str!("banner.txt"); println!("{}", banner.cyan().bold()); diff --git a/src/mcp.rs b/src/mcp.rs index 1bdc1df5..2618aa29 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1226,7 +1226,25 @@ impl McpGenerator { } } - /// Generate MCP config for a specific agent + /// Generates and synchronizes the MCP configuration for one agent. + /// + /// Disabled servers are excluded from the generated configuration. + /// + /// # Parameters + /// + /// * `project_root` β€” Root directory used to resolve project-specific configuration paths. + /// * `dry_run` β€” Reports the intended operation without modifying files. + /// + /// # Returns + /// + /// The synchronization result, or an error if configuration generation fails. + /// + /// # Examples + /// + /// ```rust,ignore + /// let result = generator.generate_for_agent(agent, project_root, false)?; + /// ``` + /// pub fn generate_for_agent( &self, agent: McpAgent, @@ -1237,7 +1255,30 @@ impl McpGenerator { self.generate_for_agent_with_servers(agent, project_root, &enabled_servers, dry_run) } - /// Resolve the content to write for an MCP config file, returning (content, existing_content). + /// Resolves the configuration content to write while applying the configured merge strategy. + /// + /// Existing content is returned when the configuration file is read; otherwise, the second tuple + /// element is `None`. Merge operations remove obsolete servers when necessary, and overwrite + /// operations preserve formatter-specific settings. + /// + /// # Errors + /// + /// Returns an error if an existing configuration cannot be read or parsed, or if the formatter + /// cannot generate, merge, or clean up the configuration content. + /// + /// # Examples + /// + /// ``` + /// # // The generator, formatter, path, and enabled servers are prepared by the caller. + /// # let (content, existing) = generator.resolve_config_content( + /// # formatter, + /// # config_path, + /// # &enabled_servers, + /// # )?; + /// # assert!(!content.is_empty()); + /// # let _ = existing; + /// # Ok::<(), anyhow::Error>(()) + /// ``` fn resolve_config_content( &self, formatter: &dyn McpFormatter, @@ -1279,7 +1320,25 @@ impl McpGenerator { } } - /// Check if config content is identical to what's already on disk. + /// Determines whether configuration content matches the existing file content. + /// + /// Uses the provided existing content when available; otherwise, reads the + /// configuration file. Returns `false` when the file cannot be read. + /// + /// # Examples + /// + /// ```ignore + /// assert!(is_content_identical( + /// Path::new("config.json"), + /// "{}", + /// Some("{}".to_owned()), + /// )); + /// ``` + fn is_content_identical( + config_path: &Path, + content: &str, + existing_content: Option, + ) -> bool fn is_content_identical( config_path: &Path, content: &str, @@ -1292,7 +1351,37 @@ impl McpGenerator { } } - /// Write config (or report what would be done in dry-run mode), returning result delta. + /// Writes the generated configuration or reports the intended action in dry-run mode. + /// + /// # Parameters + /// + /// * `config_path` β€” Path of the configuration file. + /// * `content` β€” Complete configuration content to write. + /// * `was_existing` β€” Whether the configuration file already exists. + /// * `dry_run` β€” Whether to report the action without writing the file. + /// + /// # Returns + /// + /// A synchronization result recording whether the configuration was created or updated. + /// + /// # Errors + /// + /// Returns an error if writing the configuration or setting its restricted permissions fails. + /// + /// # Examples + /// + /// ```no_run + /// # let generator = todo!(); + /// # let config_path = std::path::Path::new("mcp.json"); + /// let result = generator.write_or_report_config( + /// config_path, + /// "{}", + /// false, + /// true, + /// )?; + /// assert_eq!(result.created, 1); + /// # Ok::<(), anyhow::Error>(()) + /// ``` fn write_or_report_config( &self, config_path: &Path, @@ -1344,7 +1433,35 @@ impl McpGenerator { Ok(result) } - /// Internal method to generate config using pre-calculated enabled servers + /// Generates and synchronizes an agent configuration from a precomputed set of enabled servers. + /// + /// The operation is skipped when the agent has no resolvable configuration path or when the server + /// set is empty. Existing configuration content is preserved according to the configured merge + /// strategy, and dry-run mode reports changes without writing files. + /// + /// # Examples + /// + /// ```ignore + /// let result = generator.generate_for_agent_with_servers( + /// agent, + /// project_root, + /// &enabled_servers, + /// false, + /// )?; + /// assert_eq!(result.failed, 0); + /// # Ok::<(), anyhow::Error>(()) + /// ``` + /// + /// # Arguments + /// + /// * `agent` - Agent whose configuration should be generated. + /// * `project_root` - Project root used to resolve project-scoped configuration paths. + /// * `enabled_servers` - Enabled server definitions to include. + /// * `dry_run` - Whether to report changes without modifying the filesystem. + /// + /// # Returns + /// + /// A synchronization result describing created, updated, and skipped configurations. fn generate_for_agent_with_servers( &self, agent: McpAgent, diff --git a/src/skills/detect.rs b/src/skills/detect.rs index 4b65d58f..55c92033 100644 --- a/src/skills/detect.rs +++ b/src/skills/detect.rs @@ -107,6 +107,18 @@ struct RepoMetadata { } impl RepoMetadata { + /// Collects filesystem metadata and nested project directories beneath the project root. + /// + /// The traversal is bounded by the configured discovery depth and excludes ignored directories. + /// + /// # Examples + /// + /// ``` + /// use std::path::Path; + /// + /// let metadata = RepoMetadata::collect(Path::new(".")); + /// assert!(metadata.paths.iter().all(|path| !path.is_absolute())); + /// ``` fn collect(project_root: &Path) -> Self { let mut paths = HashSet::new(); let mut dirs = HashSet::new(); @@ -151,6 +163,24 @@ impl RepoMetadata { } } + /// Records a directory in the directory set and records depth-one directories as root directories. + /// + /// # Examples + /// + /// ```rust,ignore + /// use std::collections::HashSet; + /// use std::path::PathBuf; + /// use walkdir::WalkDir; + /// + /// let mut root_dirs = Vec::new(); + /// let mut dirs = HashSet::new(); + /// + /// for entry in WalkDir::new(".") { + /// let entry = entry.unwrap(); + /// let relative_path = PathBuf::from(entry.path()); + /// process_dir_entry(&entry, &relative_path, &mut root_dirs, &mut dirs); + /// } + /// ``` fn process_dir_entry( entry: &walkdir::DirEntry, relative_buf: &Path, @@ -165,6 +195,17 @@ impl RepoMetadata { } } + /// Records the parent directory of a nested project manifest unless it is at the repository root or in a test directory. + /// + /// # Examples + /// + /// ``` + /// let mut nested_projects = BTreeSet::new(); + /// + /// check_nested_project(Path::new("frontend/package.json"), &mut nested_projects); + /// + /// assert!(nested_projects.contains(Path::new("frontend"))); + /// ``` fn check_nested_project(relative: &Path, nested_projects: &mut BTreeSet) { let file_name = relative.file_name().and_then(|n| n.to_str()).unwrap_or(""); if !PROJECT_MANIFEST_FILES.contains(&file_name) { @@ -180,6 +221,23 @@ impl RepoMetadata { } } + /// Records the first path associated with a file extension. + /// + /// The extension is stored with and without its leading dot. Existing entries + /// are preserved. + /// + /// # Examples + /// + /// ``` + /// use std::collections::HashMap; + /// use std::path::Path; + /// + /// let mut extensions = HashMap::new(); + /// record_extension(Path::new("src/main.rs"), Path::new("src/main.rs"), &mut extensions); + /// + /// assert_eq!(extensions.get("rs"), Some(&"src/main.rs".into())); + /// assert_eq!(extensions.get(".rs"), Some(&"src/main.rs".into())); + /// ``` fn record_extension( relative: &Path, relative_buf: &Path, @@ -353,6 +411,21 @@ impl CatalogDrivenDetector { } impl RepoDetector for CatalogDrivenDetector { + /// Detects catalog-defined technologies in a project and its discovered nested projects. + /// + /// # Examples + /// + /// ```no_run + /// let mut cache = ContentCache::default(); + /// let detections = detector + /// .detect(std::path::Path::new("."), &mut cache) + /// .unwrap(); + /// assert!(detections.iter().all(|d| !d.technology_id.is_empty())); + /// ``` + /// + /// # Returns + /// + /// A list of technology detections for the project and nested projects. fn detect( &self, project_root: &Path, @@ -390,6 +463,28 @@ impl RepoDetector for CatalogDrivenDetector { } } +/// Detects technologies used by nested projects and appends new detections with paths relative to the repository root. +/// +/// Existing detections take precedence, so a technology is recorded only once. +/// +/// # Examples +/// +/// ```ignore +/// detect_nested_projects( +/// project_root, +/// metadata, +/// &rules, +/// &mut cache, +/// &mut detections, +/// ); +/// ``` +fn detect_nested_projects( +project_root: &Path, +metadata: &RepoMetadata, +rules: &[(TechnologyId, CompiledDetectionRules)], +cache: &mut ContentCache, +detections: &mut Vec, +) { fn detect_nested_projects( project_root: &Path, metadata: &RepoMetadata, @@ -421,6 +516,15 @@ fn detect_nested_projects( } } +/// Adjusts a nested detection so its paths are relative to the repository root. +/// +/// # Examples +/// +/// ``` +/// # let detection: TechnologyDetection = todo!(); +/// let adjusted = adjust_detection(detection, Path::new("packages/app")); +/// # let _ = adjusted; +/// ``` fn adjust_detection(detection: TechnologyDetection, offset: &Path) -> TechnologyDetection { TechnologyDetection { technology: detection.technology, @@ -442,6 +546,38 @@ fn adjust_detection(detection: TechnologyDetection, offset: &Path) -> Technology } } +/// Evaluates detection rules in precedence order and returns the first matching technology detection. +/// +/// Package matches take precedence over configuration-file and extension matches. Returns `None` +/// when no configured rule matches the project metadata. +/// +/// # Examples +/// +/// ```rust,ignore +/// let detection = evaluate_rules( +/// project_root, +/// &technology_id, +/// &rules, +/// &all_packages, +/// &metadata, +/// &mut cache, +/// ); +/// +/// assert!(detection.is_some()); +/// ``` +/// +/// # Arguments +/// +/// * `project_root` - Root directory used to resolve configuration files. +/// * `tech_id` - Technology identifier associated with the rules. +/// * `rules` - Compiled rules used for detection. +/// * `all_packages` - Dependency names discovered in the project. +/// * `metadata` - Collected project filesystem metadata. +/// * `cache` - Cache used when reading configuration-file contents. +/// +/// # Returns +/// +/// The first matching technology detection, or `None` when no rule matches. fn evaluate_rules( project_root: &Path, tech_id: &TechnologyId, @@ -465,6 +601,19 @@ fn evaluate_rules( check_file_extensions(tech_id, rules, metadata) } +/// Detects a technology when one of its exact package names appears in the dependency set. +/// +/// # Returns +/// +/// A high-confidence detection for the first matching package, or `None` when no exact +/// package rule matches. +/// +/// # Examples +/// +/// ```rust,ignore +/// let detection = check_exact_packages(&tech_id, &rules, &all_packages); +/// assert_eq!(detection.unwrap().confidence, DetectionConfidence::High); +/// ``` fn check_exact_packages( tech_id: &TechnologyId, rules: &CompiledDetectionRules, @@ -484,6 +633,21 @@ fn check_exact_packages( None } +/// Finds a package-pattern rule that matches one of the detected package names. +/// +/// Matching uses the configured pattern order and returns the first matching package +/// with medium detection confidence. +/// +/// # Returns +/// +/// A detection for the first matching package, or `None` when no pattern matches. +/// +/// # Examples +/// +/// ```ignore +/// let detection = check_package_patterns(&tech_id, &rules, &all_packages); +/// assert!(detection.is_some()); +/// ``` fn check_package_patterns( tech_id: &TechnologyId, rules: &CompiledDetectionRules, @@ -505,6 +669,15 @@ fn check_package_patterns( None } +/// Detects a technology when one of its configured files exists in the project. +/// +/// # Examples +/// +/// ``` +/// # // Example usage within the detector module. +/// # let detection = check_config_files(&tech_id, &rules, project_root, &metadata); +/// # assert!(detection.is_some() || detection.is_none()); +/// ``` fn check_config_files( tech_id: &TechnologyId, rules: &CompiledDetectionRules, @@ -526,6 +699,27 @@ fn check_config_files( None } +/// Finds a configuration-file content pattern that identifies a technology. +/// +/// Scans the applicable project files and returns the first matching detection. +/// +/// # Examples +/// +/// ```ignore +/// let detection = check_config_file_content( +/// &tech_id, +/// &rules, +/// project_root, +/// &metadata, +/// &mut cache, +/// ); +/// assert!(detection.is_some()); +/// ``` +/// +/// # Returns +/// +/// A medium-confidence detection for the first matching pattern, or `None` if +/// no configured file contains a matching pattern. fn check_config_file_content( tech_id: &TechnologyId, rules: &CompiledDetectionRules, @@ -555,6 +749,14 @@ fn check_config_file_content( None } +/// Identifies the repository technology from a matching file extension. +/// +/// # Examples +/// +/// ```ignore +/// let detection = check_file_extensions(&tech_id, &rules, &metadata); +/// assert!(detection.is_some()); +/// ``` fn check_file_extensions( tech_id: &TechnologyId, rules: &CompiledDetectionRules, @@ -594,6 +796,17 @@ fn make_detection( } } +/// Collects the files to scan for configuration-content detection. +/// +/// Includes files from the configured Gradle layout and explicitly configured +/// paths, while retaining only files recognized by the repository metadata. +/// +/// # Examples +/// +/// ```ignore +/// let files = gather_content_scan_files(project_root, &rules, &metadata); +/// assert!(files.iter().all(|path| path.is_file())); +/// ``` fn gather_content_scan_files( project_root: &Path, rules: &CompiledConfigFileContentRules, @@ -612,6 +825,19 @@ fn gather_content_scan_files( files } +/// Collects recognized Gradle build and version catalog files present in the repository metadata. +/// +/// Files at the repository root and in its immediate root directories are appended to `files`. +/// +/// # Examples +/// +/// ``` +/// let metadata = RepoMetadata::default(); +/// let mut files = Vec::new(); +/// +/// gather_gradle_files(&metadata, &mut files); +/// assert!(files.is_empty()); +/// ``` fn gather_gradle_files(metadata: &RepoMetadata, files: &mut Vec) { for name in &[ "build.gradle.kts", @@ -636,6 +862,20 @@ fn gather_gradle_files(metadata: &RepoMetadata, files: &mut Vec) { } } +/// Adds existing explicit files to the collection, avoiding duplicates. +/// +/// # Arguments +/// +/// * `project_root` - Root directory used to resolve explicit file paths. +/// * `explicit_files` - Paths explicitly selected for content scanning. +/// * `metadata` - Repository metadata containing discovered paths. +/// * `files` - Collection to which eligible paths are added. +/// +/// # Examples +/// +/// ```rust,ignore +/// gather_explicit_files(&project_root, &explicit_files, &metadata, &mut files); +/// ``` fn gather_explicit_files( project_root: &Path, explicit_files: &[PathBuf], @@ -827,6 +1067,21 @@ fn canonical_existing_path(path: &Path) -> Result { .with_context(|| format!("failed to resolve path {}", path.display())) } +/// Parses a `pyproject.toml` file and collects its declared Python dependencies. +/// +/// Returns `None` when the file cannot be resolved, read, or parsed. Otherwise, returns the +/// dependency names declared using supported PEP 621 and Poetry formats. +/// +/// # Examples +/// +/// ``` +/// let mut cache = ContentCache::default(); +/// let dependencies = parse_pyproject_toml_deps( +/// std::path::Path::new("pyproject.toml"), +/// &mut cache, +/// ); +/// assert!(dependencies.is_some() || dependencies.is_none()); +/// ``` fn parse_pyproject_toml_deps(path: &Path, cache: &mut ContentCache) -> Option> { let path = canonical_existing_path(path).ok()?; let content = get_file_content(&path, cache)?; @@ -839,6 +1094,32 @@ fn parse_pyproject_toml_deps(path: &Path, cache: &mut ContentCache) -> Option=2"] +/// +/// [project.optional-dependencies] +/// test = ["pytest"] +/// "#, +/// ).unwrap(); +/// let mut deps = std::collections::BTreeSet::new(); +/// +/// collect_pep621_deps(&value, &mut deps); +/// +/// assert!(deps.contains("requests")); +/// assert!(deps.contains("pytest")); +/// ``` fn collect_pep621_deps(value: &toml::Value, deps: &mut BTreeSet) { let Some(project) = value.get("project").and_then(|v| v.as_table()) else { return; @@ -856,6 +1137,31 @@ fn collect_pep621_deps(value: &toml::Value, deps: &mut BTreeSet) { } } +/// Collects Poetry dependency names from a TOML document into a set. +/// +/// Dependencies from the main, grouped, and development Poetry sections are included. +/// +/// # Examples +/// +/// ``` +/// let value: toml::Value = r#" +/// [tool.poetry.dependencies] +/// python = "^3.11" +/// requests = "^2.31" +/// +/// [tool.poetry.dev-dependencies] +/// pytest = "^7" +/// "# +/// .parse() +/// .unwrap(); +/// let mut dependencies = std::collections::BTreeSet::new(); +/// +/// collect_poetry_deps(&value, &mut dependencies); +/// +/// assert!(dependencies.contains("python")); +/// assert!(dependencies.contains("requests")); +/// assert!(dependencies.contains("pytest")); +/// ``` fn collect_poetry_deps(value: &toml::Value, deps: &mut BTreeSet) { let Some(poetry) = value .get("tool") @@ -881,6 +1187,19 @@ fn collect_poetry_deps(value: &toml::Value, deps: &mut BTreeSet) { } } +/// Parses package and development dependencies from a Pipfile. +/// +/// Returns `None` when the file cannot be resolved, read, or parsed as TOML. +/// +/// # Examples +/// +/// ```no_run +/// use std::path::Path; +/// +/// let mut cache = ContentCache::default(); +/// let dependencies = parse_pipfile_deps(Path::new("Pipfile"), &mut cache); +/// assert!(dependencies.is_some()); +/// ``` fn parse_pipfile_deps(path: &Path, cache: &mut ContentCache) -> Option> { let path = canonical_existing_path(path).ok()?; let content = get_file_content(&path, cache)?; @@ -1002,6 +1321,20 @@ fn parse_package_json_workspaces(workspaces: &serde_json::Value) -> Vec Vec::new() } +/// Expands workspace path patterns into existing workspace directories containing a `package.json` manifest. +/// +/// Supports exact paths and one-level wildcard directory patterns relative to the project root. +/// +/// # Examples +/// +/// ``` +/// let metadata = RepoMetadata::default(); +/// let workspaces = expand_workspace_patterns( +/// Path::new("."), +/// &["packages/*".to_owned()], +/// &metadata, +/// ); +/// ``` fn expand_workspace_patterns( project_root: &Path, patterns: &[String], @@ -1027,6 +1360,21 @@ fn expand_workspace_patterns( dirs } +/// Expands a one-level workspace wildcard by adding child directories that contain a `package.json` manifest. +/// +/// # Arguments +/// +/// * `project_root` - Absolute root directory of the project. +/// * `base_rel` - Relative directory containing the workspace’s immediate children. +/// * `metadata` - Repository metadata used to identify directories and manifests. +/// * `dirs` - Collection to which matching workspace directories are appended. +/// +/// # Examples +/// +/// ```rust,ignore +/// expand_glob_workspace(&project_root, Path::new("packages"), &metadata, &mut dirs); +/// assert!(dirs.iter().all(|dir| dir.join("package.json").exists())); +/// ``` fn expand_glob_workspace( project_root: &Path, base_rel: &Path, @@ -1044,6 +1392,18 @@ fn expand_glob_workspace( } } +/// Adds an exact workspace directory when its `package.json` manifest exists. +/// +/// # Examples +/// +/// ```no_run +/// expand_exact_workspace( +/// project_root, +/// base_rel, +/// metadata, +/// &mut workspace_dirs, +/// ); +/// ``` fn expand_exact_workspace( project_root: &Path, base_rel: &Path, diff --git a/src/skills/install.rs b/src/skills/install.rs index dce6f31e..79a0ffc1 100644 --- a/src/skills/install.rs +++ b/src/skills/install.rs @@ -238,6 +238,21 @@ fn find_best_skill_dir(temp_path: &Path, skill_id: &str) -> PathBuf { temp_path.to_path_buf() } +/// Fetches a local or remote skill archive and unpacks it into a temporary directory. +/// +/// A URL fragment selects a subpath within the archive. ZIP and gzip-compressed tar +/// archives are supported. +/// +/// # Examples +/// +/// ```no_run +/// # #[tokio::main] +/// # async fn main() -> Result<(), SkillInstallError> { +/// let temp_dir = fetch_and_unpack_to_tempdir("https://example.com/skill.zip").await?; +/// assert!(temp_dir.path().exists()); +/// # Ok(()) +/// # } +/// ``` pub async fn fetch_and_unpack_to_tempdir(url: &str) -> Result { use std::io::Cursor; @@ -271,6 +286,29 @@ pub async fn fetch_and_unpack_to_tempdir(url: &str) -> Result Result<(), SkillInstallError> { +/// let (data, extension) = fetch_remote_data("https://example.com/skill.zip", tmp_path).await?; +/// assert!(!data.is_empty()); +/// assert_eq!(extension, "zip"); +/// # Ok(()) +/// # } +/// ``` +/// +/// The URL must respond successfully; network and local storage failures are returned as +/// `SkillInstallError` values. +async fn fetch_remote_data( +url_base: &str, +tmp_path: &std::path::Path, +) -> Result<(Vec, String), SkillInstallError> { async fn fetch_remote_data( url_base: &str, tmp_path: &std::path::Path, @@ -351,6 +426,29 @@ async fn fetch_remote_data( Ok((data, ext)) } +/// Extracts ZIP archive entries into a destination directory, optionally restricting extraction to a subpath. +/// +/// Archive paths are validated before extraction, and unsafe paths cause an error. +/// +/// # Arguments +/// +/// * `reader` - A seekable reader containing the ZIP archive. +/// * `dest` - Directory where selected entries are extracted. +/// * `subpath` - Optional path within the archive to extract. +/// +/// # Errors +/// +/// Returns an error if the archive cannot be read, an entry has an unsafe path, or extraction fails. +/// +/// # Examples +/// +/// ``` +/// use std::io::Cursor; +/// use std::path::Path; +/// +/// let result = unpack_zip(Cursor::new(Vec::::new()), Path::new("output"), None); +/// assert!(result.is_err()); +/// ``` fn unpack_zip( reader: impl std::io::Read + std::io::Seek, dest: &std::path::Path, @@ -391,6 +489,31 @@ fn unpack_zip( Ok(()) } +/// Identifies a shared top-level directory among ZIP entries. +/// +/// # Examples +/// +/// ``` +/// use std::io::{Cursor, Write}; +/// use zip::{write::SimpleFileOptions, ZipArchive, ZipWriter}; +/// +/// let mut data = Cursor::new(Vec::new()); +/// { +/// let mut writer = ZipWriter::new(&mut data); +/// writer +/// .start_file("skill/SKILL.md", SimpleFileOptions::default()) +/// .unwrap(); +/// writer.write_all(b"content").unwrap(); +/// writer.finish().unwrap(); +/// } +/// +/// data.set_position(0); +/// let mut archive = ZipArchive::new(data).unwrap(); +/// assert_eq!( +/// zip_common_root(&mut archive).unwrap(), +/// Some("skill".to_owned()) +/// ); +/// ``` fn zip_common_root( zip: &mut ZipArchive, ) -> Result, SkillInstallError> { @@ -410,6 +533,19 @@ fn zip_common_root( } } +/// Computes the path of a ZIP entry relative to an optional archive root and subpath. +/// +/// # Examples +/// +/// ``` +/// let path = zip_entry_rel_path( +/// "project/docs/SKILL.md", +/// Some("project"), +/// Some("docs"), +/// ); +/// +/// assert_eq!(path, Some("SKILL.md")); +/// ``` fn zip_entry_rel_path<'a>( full_name: &'a str, common_root: Option<&str>, @@ -433,6 +569,29 @@ fn zip_entry_rel_path<'a>( } } +/// Extracts supported entries from a gzip-compressed tar archive into a destination directory. +/// +/// Archive paths are validated before extraction, and an optional subpath limits the entries +/// that are unpacked. Files and directories are extracted; other entry types are skipped. +/// +/// # Examples +/// +/// ``` +/// use flate2::{write::GzEncoder, Compression}; +/// use std::io::Cursor; +/// +/// let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); +/// { +/// let mut builder = tar::Builder::new(&mut encoder); +/// builder.finish().unwrap(); +/// } +/// let archive = encoder.finish().unwrap(); +/// +/// let dest = std::env::temp_dir().join("skill-install-example"); +/// std::fs::create_dir_all(&dest).unwrap(); +/// unpack_tar_gz(Cursor::new(archive), &dest, None).unwrap(); +/// std::fs::remove_dir_all(dest).unwrap(); +/// ``` fn unpack_tar_gz( reader: impl std::io::Read, dest: &std::path::Path, @@ -479,6 +638,30 @@ fn unpack_tar_gz( Ok(()) } +/// Determines whether all archive entries share the same top-level directory. +/// +/// Returns the shared directory name when every entry starts with it; otherwise, returns `None`. +/// +/// # Examples +/// +/// ``` +/// use flate2::{Compression, read::GzDecoder, write::GzEncoder}; +/// use std::io::Write; +/// use tar::{Archive, Builder}; +/// +/// let mut data = Vec::new(); +/// { +/// let encoder = GzEncoder::new(&mut data, Compression::default()); +/// let mut builder = Builder::new(encoder); +/// builder.finish().unwrap(); +/// } +/// +/// let decoder = GzDecoder::new(&data[..]); +/// let mut archive = Archive::new(decoder); +/// let entries: Vec<_> = archive.entries().unwrap().collect(); +/// +/// assert_eq!(tar_common_root(&entries).unwrap(), None); +/// ``` fn tar_common_root( entries: &[Result>, std::io::Error>], ) -> Result, SkillInstallError> { @@ -513,6 +696,27 @@ fn tar_common_root( } } +/// Produces the extraction-relative path for a tar archive entry. +/// +/// The common archive root is removed when present. If a subpath is provided, +/// the entry must be within that subpath, which is also removed from the +/// resulting path. +/// +/// # Examples +/// +/// ``` +/// use std::path::Path; +/// +/// let path = tar_entry_rel_path( +/// Path::new("skills/example/SKILL.md"), +/// Some("skills"), +/// Some("example"), +/// ); +/// +/// assert_eq!(path, Some(std::path::PathBuf::from("SKILL.md"))); +/// ``` +/// +/// Returns `None` when the entry does not belong to the requested subpath. fn tar_entry_rel_path( full_path: &std::path::Path, common_root: Option<&str>, diff --git a/src/skills/suggest.rs b/src/skills/suggest.rs index de5dd1e5..9365c08f 100644 --- a/src/skills/suggest.rs +++ b/src/skills/suggest.rs @@ -687,6 +687,17 @@ impl SuggestResponse { } impl SuggestInstallJsonResponse { + /// Renders the installation response as a human-readable summary. + /// + /// # Examples + /// + /// ``` + /// # fn example(response: &SuggestInstallJsonResponse) { + /// let rendered = response.render_human(); + /// assert!(!rendered.is_empty()); + /// # } + /// ``` + pub fn render_human(&self) -> String pub fn render_human(&self) -> String { let mut lines = Vec::new(); @@ -708,6 +719,15 @@ impl SuggestInstallJsonResponse { } } +/// Formats a skill recommendation's installation status, including its installed version when available. +/// +/// # Examples +/// +/// ```no_run +/// let recommendation: SkillSuggestion = todo!(); +/// let status = format_installed_status(&recommendation); +/// assert!(status == "installed" || status == "not installed"); +/// ``` #[allow(dead_code)] fn format_installed_status(recommendation: &SkillSuggestion) -> String { if recommendation.installed { @@ -720,6 +740,15 @@ fn format_installed_status(recommendation: &SkillSuggestion) -> String { } } +/// Renders detected technologies and their evidence as human-readable lines. +/// +/// # Examples +/// +/// ``` +/// let mut lines = Vec::new(); +/// render_detections_section(&[], &mut lines); +/// assert_eq!(lines, vec!["Detected technologies: none"]); +/// ``` fn render_detections_section(detections: &[SuggestJsonDetection], lines: &mut Vec) { if detections.is_empty() { lines.push("Detected technologies: none".to_string()); @@ -736,6 +765,16 @@ fn render_detections_section(detections: &[SuggestJsonDetection], lines: &mut Ve } } +/// Renders recommended skills and their installation status and reasons into text lines. +/// +/// # Examples +/// +/// ``` +/// let mut lines = Vec::new(); +/// render_recommendations_section(&[], &mut lines); +/// +/// assert_eq!(lines, vec!["Recommended skills: none"]); +/// ``` fn render_recommendations_section( recommendations: &[SuggestJsonRecommendation], lines: &mut Vec, @@ -758,6 +797,16 @@ fn render_recommendations_section( } } +/// Appends a summary of the selected skill IDs to the output lines. +/// +/// # Examples +/// +/// ``` +/// let mut lines = Vec::new(); +/// render_selected_skills_section(&["lint".to_string()], &mut lines); +/// +/// assert_eq!(lines, vec!["Selected skills: lint"]); +/// ``` fn render_selected_skills_section(selected_skill_ids: &[String], lines: &mut Vec) { if selected_skill_ids.is_empty() { lines.push("Selected skills: none".to_string()); @@ -769,6 +818,20 @@ fn render_selected_skills_section(selected_skill_ids: &[String], lines: &mut Vec } } +/// Appends a human-readable installation-results section to the output lines. +/// +/// # Examples +/// +/// ``` +/// let mut lines = Vec::new(); +/// render_install_results_section(&[], &mut lines); +/// assert_eq!(lines, vec!["Install results: none"]); +/// ``` +/// +/// # Parameters +/// +/// * `results` β€” Installation results to render. +/// * `lines` β€” Output lines to which the section is appended. fn render_install_results_section(results: &[SuggestInstallResult], lines: &mut Vec) { if results.is_empty() { lines.push("Install results: none".to_string()); diff --git a/src/skills/update.rs b/src/skills/update.rs index 2171fed4..cef16eff 100644 --- a/src/skills/update.rs +++ b/src/skills/update.rs @@ -20,6 +20,36 @@ pub enum SkillUpdateError { Validation(String), } +/// Updates an installed skill from a local or remote source. +/// +/// The candidate version must be newer than the currently installed version. +/// The existing skill and registry entry are restored if installation fails. +/// +/// # Arguments +/// +/// * `skill_id` - Identifier of the skill to update. +/// * `target_root` - Directory containing the installed skill and registry. +/// * `update_source` - Path or URL identifying the candidate skill. +/// +/// # Errors +/// +/// Returns an error if the source cannot be resolved, the candidate version is +/// invalid or not newer, or backup, installation, validation, or registry +/// operations fail. +/// +/// # Examples +/// +/// ``` +/// # use std::path::Path; +/// # async fn example() -> Result<(), Box> { +/// update_skill_async( +/// "example", +/// Path::new("./skills"), +/// Path::new("./example-update"), +/// ).await?; +/// # Ok(()) +/// # } +/// ``` pub async fn update_skill_async( skill_id: &str, target_root: &Path, @@ -47,6 +77,26 @@ pub async fn update_skill_async( ) } +/// Resolves a local path or fetches a remote archive into a temporary directory. +/// +/// Remote URLs and archive paths are unpacked into a temporary directory whose +/// lifetime is retained by the returned `TempDir`. +/// +/// # Returns +/// +/// A tuple containing the resolved skill path and the temporary directory that +/// owns it, if the source was fetched remotely. +/// +/// # Examples +/// +/// ``` +/// # async fn example() -> Result<(), SkillUpdateError> { +/// let (path, _temporary_dir) = +/// resolve_update_source(std::path::Path::new("./skill")).await?; +/// assert_eq!(path, std::path::PathBuf::from("./skill")); +/// # Ok(()) +/// # } +/// ``` async fn resolve_update_source( update_source: &Path, ) -> Result<(std::path::PathBuf, Option), SkillUpdateError> { @@ -66,6 +116,21 @@ async fn resolve_update_source( } } +/// Resolves the installed version of a skill from the registry or its `SKILL.md` manifest. +/// +/// # Examples +/// +/// ``` +/// use std::path::Path; +/// +/// let version = resolve_current_version( +/// "example-skill", +/// Path::new("missing-skill"), +/// Path::new("missing-registry.json"), +/// ); +/// +/// assert_eq!(version, None); +/// ``` fn resolve_current_version( skill_id: &str, skill_dir: &Path, @@ -105,6 +170,23 @@ fn resolve_current_version( None } +/// Validates that a skill manifest contains a semantic version newer than the installed version. +/// +/// A missing or invalid installed version is treated as `0.0.0`. +/// +/// # Examples +/// +/// ```no_run +/// use std::path::Path; +/// +/// let installed_version = Some(String::from("1.2.0")); +/// validate_version_upgrade(Path::new("/path/to/skill"), &installed_version)?; +/// # Ok::<(), SkillUpdateError>(()) +/// ``` +fn validate_version_upgrade( +local_dir: &Path, +current_version: &Option, +) -> Result<(), SkillUpdateError> { fn validate_version_upgrade( local_dir: &Path, current_version: &Option, @@ -134,6 +216,30 @@ fn validate_version_upgrade( Ok(()) } +/// Moves an existing skill directory to a backup location, replacing any existing backup. +/// +/// If the skill directory does not exist, no action is taken. +/// +/// # Errors +/// +/// Returns [`SkillUpdateError::Atomic`] if removing the existing backup or moving the skill fails. +/// +/// # Examples +/// +/// ``` +/// use std::fs; +/// +/// let root = std::env::temp_dir().join("skill-update-example"); +/// let skill_dir = root.join("skill"); +/// let backup_dir = root.join("backup"); +/// fs::create_dir_all(&skill_dir).unwrap(); +/// +/// create_backup(&skill_dir, &backup_dir).unwrap(); +/// +/// assert!(!skill_dir.exists()); +/// assert!(backup_dir.exists()); +/// fs::remove_dir_all(root).unwrap(); +/// ``` fn create_backup(skill_dir: &Path, backup_dir: &Path) -> Result<(), SkillUpdateError> { use std::fs; if skill_dir.exists() { @@ -145,6 +251,25 @@ fn create_backup(skill_dir: &Path, backup_dir: &Path) -> Result<(), SkillUpdateE Ok(()) } +/// Installs an updated skill and records its manifest in the registry. +/// +/// Restores the previous skill and registry entry when manifest validation or registry updates fail. +/// +/// # Errors +/// +/// Returns [`SkillUpdateError::Io`] if copying the candidate skill fails, [`SkillUpdateError::Install`] if its manifest is invalid, [`SkillUpdateError::Registry`] if the registry cannot be updated, or [`SkillUpdateError::Atomic`] if replacing the existing skill fails. +/// +/// # Examples +/// +/// ```no_run +/// # use std::path::Path; +/// # let candidate = Path::new("/tmp/candidate-skill"); +/// # let skill = Path::new("/tmp/skills/example"); +/// # let backup = Path::new("/tmp/skills/example.backup"); +/// # let registry = Path::new("/tmp/skills/registry.json"); +/// install_updated_skill("example", candidate, skill, backup, registry)?; +/// # Ok::<(), SkillUpdateError>(()) +/// ``` fn install_updated_skill( skill_id: &str, local_dir: &Path, @@ -207,6 +332,20 @@ fn install_updated_skill( Ok(()) } +/// Retrieves a skill's registry entry from the registry file. +/// +/// # Examples +/// +/// ``` +/// use std::path::Path; +/// +/// let entry = read_old_registry_entry("example-skill", Path::new("missing-registry.json")); +/// assert!(entry.is_none()); +/// ``` +/// +/// # Returns +/// +/// The registry entry for `skill_id`, or `None` if the registry or skill entry is unavailable. fn read_old_registry_entry( skill_id: &str, registry_path: &Path, @@ -219,7 +358,26 @@ fn read_old_registry_entry( skills.get(skill_id).cloned() } -/// Recursively copies a directory (src) to dst. +/// Recursively copies a directory and its regular contents to a destination, skipping symbolic links. +/// +/// # Errors +/// +/// Returns an I/O error if the source cannot be read or the destination cannot be created or written. +/// +/// # Examples +/// +/// ``` +/// # use std::fs; +/// # use std::path::PathBuf; +/// # let root = std::env::temp_dir().join(format!("copy-dir-all-{}", std::process::id())); +/// # let src = root.join("src"); +/// # let dst = root.join("dst"); +/// # fs::create_dir_all(&src).unwrap(); +/// # fs::write(src.join("SKILL.md"), "content").unwrap(); +/// copy_dir_all(&src, &dst).unwrap(); +/// assert_eq!(fs::read_to_string(dst.join("SKILL.md")).unwrap(), "content"); +/// # fs::remove_dir_all(root).unwrap(); +/// ``` fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> { use std::fs; if !dst.exists() { diff --git a/src/update_check.rs b/src/update_check.rs index 4bb7f031..2eb506b8 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -51,6 +51,23 @@ fn cache_path() -> PathBuf { .join("update-check.json") } +/// Determines whether cached update information is still valid. +/// +/// A cache is fresh when it was checked within the cache lifetime and the +/// cached version has already been recorded as notified. +/// +/// # Examples +/// +/// ``` +/// let now = chrono::Utc::now().timestamp(); +/// let cache = CheckedVersion { +/// last_checked: now, +/// latest_version: "1.0.0".to_owned(), +/// notified_for_version: Some("1.0.0".to_owned()), +/// }; +/// +/// assert!(is_fresh(&cache)); +/// ``` fn is_fresh(cache: &CheckedVersion) -> bool { let now = chrono::Utc::now().timestamp(); if now - cache.last_checked > CACHE_TTL_SECS { @@ -62,6 +79,21 @@ fn is_fresh(cache: &CheckedVersion) -> bool { true } +/// Determines whether the update check should be skipped. +/// +/// The check is skipped when explicitly disabled, when running in continuous +/// integration, or when standard error is not attached to a terminal. +/// +/// # Examples +/// +/// ``` +/// let skip_update_check = should_skip_update_check(); +/// assert!(matches!(skip_update_check, true | false)); +/// ``` +/// +/// # Returns +/// +/// `true` if the update check should be skipped, `false` otherwise. fn should_skip_update_check() -> bool { let no_check = std::env::var("AGENTSYNC_NO_UPDATE_CHECK") .map(|v| v.eq_ignore_ascii_case("1")) @@ -80,6 +112,19 @@ fn should_skip_update_check() -> bool { !std::io::stderr().is_terminal() } +/// Retrieves the newest published `agentsync` version from crates.io. +/// +/// # Examples +/// +/// ```no_run +/// if let Some(version) = fetch_latest_version() { +/// println!("Latest version: {version}"); +/// } +/// ``` +/// +/// # Returns +/// +/// The newest published version, or `None` if the request or response cannot be processed. fn fetch_latest_version() -> Option { #[derive(Deserialize)] struct CratesIoResponse { @@ -103,6 +148,17 @@ fn fetch_latest_version() -> Option { Some(info.krate.newest_version) } +/// Checks for a newer stable version and notifies the user when one is available. +/// +/// The check is skipped when the cached result is still fresh, the latest version +/// cannot be determined, or the available version is not newer than the installed +/// version. +/// +/// # Examples +/// +/// ```no_run +/// check_and_notify(); +/// ``` fn check_and_notify() { let cache = Cache { path: cache_path() }; @@ -149,6 +205,13 @@ fn check_and_notify() { ); } +/// Starts an asynchronous check for a newer stable `agentsync` release when update checks are enabled. +/// +/// # Examples +/// +/// ``` +/// agentsync::update_check::spawn(); +/// ``` pub fn spawn() { if should_skip_update_check() { return;