diff --git a/src/ai.rs b/src/ai.rs index f01381c..1676db9 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -317,11 +317,17 @@ CRITICAL rules for the `## Public API` section: - If a symbol is private/internal (e.g. `const`, `fn` without `pub`, `mod` declarations), do NOT put it in the Public API table - Use subsection headers like `### Exported Functions`, `### Exported Types` — NOT `### Constants`, `### Per-language extractors`, `### Methods`, etc. +Context boundaries: +- This spec covers ONLY the files listed in the frontmatter — do not document symbols from imported/dependent modules +- If a file imports symbols from other modules, those belong to the dependency's spec, not this one +- Only document the public contract that THIS module exposes to its consumers +- Group related types and functions logically, not by file + Other guidelines: - For `## Invariants`, list rules that must always hold based on the code - For `## Behavioral Examples`, use Given/When/Then format - For `## Error Cases`, use a table of Condition | Behavior -- For `## Dependencies`, list what this module consumes from other modules +- For `## Dependencies`, list what this module consumes from other modules (imports from outside this module's files) - For `## Change Log`, add a single entry with today's date and "Initial spec" - Be accurate — only document what the code actually does"# ) diff --git a/src/config.rs b/src/config.rs index e49ab71..6eb4f93 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use crate::exports::has_extension; +use crate::manifest; use crate::types::SpecSyncConfig; use std::collections::HashSet; use std::fs; @@ -46,9 +47,32 @@ const IGNORED_DIRS: &[&str] = &[ "obj", ]; -/// Auto-detect source directories by scanning the project root for files -/// with supported language extensions. Returns directories relative to root. +/// Auto-detect source directories by first checking manifest files +/// (Cargo.toml, Package.swift, build.gradle.kts, package.json, etc.), +/// then falling back to scanning the project root for files with supported +/// language extensions. Returns directories relative to root. pub fn detect_source_dirs(root: &Path) -> Vec { + // Try manifest-aware detection first + let manifest_discovery = manifest::discover_from_manifests(root); + if !manifest_discovery.source_dirs.is_empty() { + let mut dirs = manifest_discovery.source_dirs; + dirs.sort(); + dirs.dedup(); + return dirs; + } + + // Fall back to directory scanning + detect_source_dirs_by_scan(root) +} + +/// Discover modules from manifest files (Package.swift, Cargo.toml, etc.). +/// Returns the manifest discovery result for use in module detection. +pub fn discover_manifest_modules(root: &Path) -> manifest::ManifestDiscovery { + manifest::discover_from_manifests(root) +} + +/// Scan-based source directory detection (fallback when no manifests found). +fn detect_source_dirs_by_scan(root: &Path) -> Vec { let ignored: HashSet<&str> = IGNORED_DIRS.iter().copied().collect(); let mut source_dirs: Vec = Vec::new(); let mut has_root_source_files = false; @@ -151,6 +175,8 @@ const KNOWN_JSON_KEYS: &[&str] = &[ "excludeDirs", "excludePatterns", "sourceExtensions", + "exportLevel", + "modules", "aiProvider", "aiModel", "aiCommand", @@ -249,6 +275,20 @@ fn load_toml_config(config_path: &Path, root: &Path) -> SpecSyncConfig { config.ai_timeout = Some(n); } } + "export_level" => { + let s = parse_toml_string(value); + match s.as_str() { + "type" => { + config.export_level = crate::types::ExportLevel::Type; + } + "member" => { + config.export_level = crate::types::ExportLevel::Member; + } + _ => eprintln!( + "Warning: unknown export_level \"{s}\" (expected \"type\" or \"member\")" + ), + } + } "required_sections" => { config.required_sections = parse_toml_string_array(value); } diff --git a/src/exports/mod.rs b/src/exports/mod.rs index 180d799..3b6af22 100644 --- a/src/exports/mod.rs +++ b/src/exports/mod.rs @@ -8,11 +8,19 @@ mod rust_lang; mod swift; mod typescript; -use crate::types::Language; +use crate::types::{ExportLevel, Language}; use std::path::Path; /// Extract exported symbol names from a source file, auto-detecting language. +/// Uses `ExportLevel::Member` (all symbols) for backwards compatibility. pub fn get_exported_symbols(file_path: &Path) -> Vec { + get_exported_symbols_with_level(file_path, ExportLevel::Member) +} + +/// Extract exported symbol names from a source file with configurable granularity. +/// When `level` is `Type`, only top-level type declarations are returned. +/// When `level` is `Member`, all public symbols are returned (default). +pub fn get_exported_symbols_with_level(file_path: &Path, level: ExportLevel) -> Vec { let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or(""); let lang = match Language::from_extension(ext) { @@ -29,9 +37,7 @@ pub fn get_exported_symbols(file_path: &Path) -> Vec { Language::TypeScript => { // Build a resolver that follows wildcard re-exports to sibling files let base_dir = file_path.parent().unwrap_or(Path::new(".")).to_path_buf(); - let resolver = move |import_path: &str| { - resolve_ts_import(&base_dir, import_path) - }; + let resolver = move |import_path: &str| resolve_ts_import(&base_dir, import_path); typescript::extract_exports_with_resolver(&content, Some(&resolver)) } Language::Rust => rust_lang::extract_exports(&content), @@ -44,6 +50,13 @@ pub fn get_exported_symbols(file_path: &Path) -> Vec { Language::Dart => dart::extract_exports(&content), }; + // If type-level granularity, filter to only type declarations + let symbols = if level == ExportLevel::Type { + filter_type_level_exports(&content, &symbols, lang) + } else { + symbols + }; + // Deduplicate preserving order let mut seen = std::collections::HashSet::new(); symbols @@ -52,6 +65,77 @@ pub fn get_exported_symbols(file_path: &Path) -> Vec { .collect() } +/// Filter symbols to only include type-level declarations (class, struct, enum, etc.). +/// This removes individual functions, variables, constants, and properties. +fn filter_type_level_exports(content: &str, symbols: &[String], lang: Language) -> Vec { + use regex::Regex; + + let type_pattern = match lang { + Language::TypeScript => { + // class, interface, type, enum — but not function, const, var, let + Regex::new( + r"(?m)export\s+(?:default\s+)?(?:abstract\s+)?(?:class|interface|type|enum)\s+(\w+)", + ) + .ok() + } + Language::Rust => { + Regex::new(r"(?m)pub(?:\(crate\))?\s+(?:struct|enum|trait|type|mod)\s+(\w+)").ok() + } + Language::Go => { + // Go: type X struct/interface + Regex::new(r"(?m)^type\s+([A-Z]\w*)\s+(?:struct|interface)").ok() + } + Language::Python => { + Regex::new(r"(?m)^class\s+(\w+)").ok() + } + Language::Swift => { + Regex::new( + r"(?m)(?:public|open)\s+(?:final\s+)?(?:class|struct|enum|protocol|actor)\s+(\w+)", + ) + .ok() + } + Language::Kotlin => { + Regex::new( + r"(?m)(?:public\s+|open\s+|abstract\s+|sealed\s+)*(?:class|interface|enum\s+class|object|data\s+class)\s+(\w+)", + ) + .ok() + } + Language::Java => { + Regex::new( + r"(?m)(?:public\s+)?(?:abstract\s+|final\s+)?(?:class|interface|enum|record)\s+(\w+)", + ) + .ok() + } + Language::CSharp => { + Regex::new( + r"(?m)(?:public\s+)?(?:abstract\s+|sealed\s+|static\s+)?(?:class|interface|enum|struct|record)\s+(\w+)", + ) + .ok() + } + Language::Dart => { + Regex::new(r"(?m)(?:abstract\s+)?class\s+(\w+)|(?m)enum\s+(\w+)").ok() + } + }; + + let type_names: std::collections::HashSet = match type_pattern { + Some(re) => re + .captures_iter(content) + .filter_map(|caps| { + caps.get(1) + .or_else(|| caps.get(2)) + .map(|m| m.as_str().to_string()) + }) + .collect(), + None => return symbols.to_vec(), + }; + + symbols + .iter() + .filter(|s| type_names.contains(s.as_str())) + .cloned() + .collect() +} + /// Resolve a TypeScript/JavaScript relative import to file content. /// Tries common extensions: .ts, .tsx, .js, .jsx, /index.ts, /index.js fn resolve_ts_import(base_dir: &Path, import_path: &str) -> Option { @@ -86,7 +170,25 @@ fn resolve_ts_import(base_dir: &Path, import_path: &str) -> Option { None } -/// Check if a file is a test file based on language conventions. +/// Well-known test directory names (case-insensitive check). +const TEST_DIR_NAMES: &[&str] = &[ + "tests", + "test", + "__tests__", + "spec", + "specs", + "testing", + "uitests", + "unittests", + "integrationtests", + "testcases", + "fixtures", + "mocks", + "stubs", + "fakes", +]; + +/// Check if a file is a test file based on language conventions and path. pub fn is_test_file(file_path: &Path) -> bool { let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -97,12 +199,23 @@ pub fn is_test_file(file_path: &Path) -> bool { let name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // Check filename patterns for pattern in lang.test_patterns() { if name.ends_with(pattern) || name.starts_with(pattern) { return true; } } + // Check if any ancestor directory is a test directory + for component in file_path.components() { + if let std::path::Component::Normal(dir) = component { + let dir_lower = dir.to_string_lossy().to_lowercase(); + if TEST_DIR_NAMES.contains(&dir_lower.as_str()) { + return true; + } + } + } + false } diff --git a/src/exports/typescript.rs b/src/exports/typescript.rs index 709a77c..6586f15 100644 --- a/src/exports/typescript.rs +++ b/src/exports/typescript.rs @@ -70,7 +70,19 @@ pub fn extract_exports_with_resolver( if let Some(name) = caps.get(1).or_else(|| caps.get(2)) { let n = name.as_str(); // Skip keyword-like default exports (e.g. `export default new ...`) - if !["new", "function", "class", "abstract", "async", "true", "false", "null", "undefined"].contains(&n) { + if ![ + "new", + "function", + "class", + "abstract", + "async", + "true", + "false", + "null", + "undefined", + ] + .contains(&n) + { symbols.push(n.to_string()); } } diff --git a/src/generator.rs b/src/generator.rs index f4b49bd..fa12028 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1,6 +1,6 @@ use crate::ai::{self, ResolvedProvider}; use crate::exports::{has_extension, is_test_file}; -use crate::types::{CoverageReport, SpecSyncConfig}; +use crate::types::{CoverageReport, Language, SpecSyncConfig}; use colored::Colorize; use std::fs; use std::io::Write; @@ -110,6 +110,349 @@ depends_on: [] |------|--------|--------| "#; +/// Detect the primary language of a set of source files. +fn detect_primary_language(files: &[String]) -> Option { + let mut counts = std::collections::HashMap::new(); + for file in files { + let ext = Path::new(file) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + if let Some(lang) = Language::from_extension(ext) { + *counts.entry(lang).or_insert(0usize) += 1; + } + } + counts.into_iter().max_by_key(|(_, c)| *c).map(|(l, _)| l) +} + +/// Get a language-specific spec template. +fn language_template(lang: Language) -> &'static str { + match lang { + Language::Swift => { + r#"--- +module: module-name +version: 1 +status: draft +files: [] +db_tables: [] +depends_on: [] +--- + +# Module Name + +## Purpose + + + +## Public API + +### Types + +| Type | Kind | Description | +|------|------|-------------| + +### Protocols + +| Protocol | Description | +|----------|-------------| + +## Invariants + +1. + +## Behavioral Examples + +### Scenario: TODO + +- **Given** precondition +- **When** action +- **Then** result + +## Error Cases + +| Condition | Behavior | +|-----------|----------| + +## Dependencies + +### Consumes + +| Module | What is used | +|--------|-------------| + +### Consumed By + +| Module | What is used | +|--------|-------------| + +## Change Log + +| Date | Author | Change | +|------|--------|--------| +"# + } + Language::Rust => { + r#"--- +module: module-name +version: 1 +status: draft +files: [] +db_tables: [] +depends_on: [] +--- + +# Module Name + +## Purpose + + + +## Public API + +### Structs & Enums + +| Type | Description | +|------|-------------| + +### Traits + +| Trait | Description | +|-------|-------------| + +### Functions + +| Function | Signature | Description | +|----------|-----------|-------------| + +## Invariants + +1. + +## Behavioral Examples + +### Scenario: TODO + +- **Given** precondition +- **When** action +- **Then** result + +## Error Cases + +| Condition | Behavior | +|-----------|----------| + +## Dependencies + +### Consumes + +| Crate/Module | What is used | +|-------------|-------------| + +### Consumed By + +| Module | What is used | +|--------|-------------| + +## Change Log + +| Date | Author | Change | +|------|--------|--------| +"# + } + Language::Kotlin | Language::Java => { + r#"--- +module: module-name +version: 1 +status: draft +files: [] +db_tables: [] +depends_on: [] +--- + +# Module Name + +## Purpose + + + +## Public API + +### Classes & Interfaces + +| Type | Kind | Description | +|------|------|-------------| + +### Functions + +| Function | Parameters | Returns | Description | +|----------|-----------|---------|-------------| + +## Invariants + +1. + +## Behavioral Examples + +### Scenario: TODO + +- **Given** precondition +- **When** action +- **Then** result + +## Error Cases + +| Condition | Behavior | +|-----------|----------| + +## Dependencies + +### Consumes + +| Module | What is used | +|--------|-------------| + +### Consumed By + +| Module | What is used | +|--------|-------------| + +## Change Log + +| Date | Author | Change | +|------|--------|--------| +"# + } + Language::Go => { + r#"--- +module: module-name +version: 1 +status: draft +files: [] +db_tables: [] +depends_on: [] +--- + +# Module Name + +## Purpose + + + +## Public API + +### Types + +| Type | Kind | Description | +|------|------|-------------| + +### Functions + +| Function | Signature | Description | +|----------|-----------|-------------| + +## Invariants + +1. + +## Behavioral Examples + +### Scenario: TODO + +- **Given** precondition +- **When** action +- **Then** result + +## Error Cases + +| Condition | Behavior | +|-----------|----------| + +## Dependencies + +### Consumes + +| Package | What is used | +|---------|-------------| + +### Consumed By + +| Package | What is used | +|---------|-------------| + +## Change Log + +| Date | Author | Change | +|------|--------|--------| +"# + } + Language::Python => { + r#"--- +module: module-name +version: 1 +status: draft +files: [] +db_tables: [] +depends_on: [] +--- + +# Module Name + +## Purpose + + + +## Public API + +### Classes + +| Class | Description | +|-------|-------------| + +### Functions + +| Function | Parameters | Returns | Description | +|----------|-----------|---------|-------------| + +## Invariants + +1. + +## Behavioral Examples + +### Scenario: TODO + +- **Given** precondition +- **When** action +- **Then** result + +## Error Cases + +| Condition | Behavior | +|-----------|----------| + +## Dependencies + +### Consumes + +| Module | What is used | +|--------|-------------| + +### Consumed By + +| Module | What is used | +|--------|-------------| + +## Change Log + +| Date | Author | Change | +|------|--------|--------| +"# + } + // TypeScript, C#, Dart, and fallback use the default template + _ => DEFAULT_TEMPLATE, + } +} + /// Find source files in a module directory. fn find_module_source_files(dir: &Path, config: &SpecSyncConfig) -> Vec { let mut results = Vec::new(); @@ -133,11 +476,27 @@ fn find_module_source_files(dir: &Path, config: &SpecSyncConfig) -> Vec .collect() } -/// Find source files for a module, checking subdirectories first, then flat files. +/// Find source files for a module, checking config module definitions first, +/// then subdirectories, then flat files. fn find_files_for_module(root: &Path, module_name: &str, config: &SpecSyncConfig) -> Vec { let mut module_files = Vec::new(); - // First: look for subdirectory-based modules (src/module_name/) + // First: check user-defined module definitions in specsync.json + if let Some(module_def) = config.modules.get(module_name) { + for file in &module_def.files { + let full_path = root.join(file); + if full_path.exists() { + module_files.push(full_path.to_string_lossy().replace('\\', "/")); + } else if full_path.is_dir() { + module_files.extend(find_module_source_files(&full_path, config)); + } + } + if !module_files.is_empty() { + return module_files; + } + } + + // Second: look for subdirectory-based modules (src/module_name/) for src_dir in &config.source_dirs { let module_dir = root.join(src_dir).join(module_name); let files = find_module_source_files(&module_dir, config); @@ -170,7 +529,7 @@ fn find_files_for_module(root: &Path, module_name: &str, config: &SpecSyncConfig module_files } -/// Generate a spec from a template. +/// Generate a spec from a template, using language-aware defaults. fn generate_spec( module_name: &str, source_files: &[String], @@ -179,9 +538,14 @@ fn generate_spec( ) -> String { let template_path = specs_dir.join("_template.spec.md"); let template = if template_path.exists() { + // User-provided template takes priority fs::read_to_string(&template_path).unwrap_or_else(|_| DEFAULT_TEMPLATE.to_string()) } else { - DEFAULT_TEMPLATE.to_string() + // Use language-specific template + match detect_primary_language(source_files) { + Some(lang) => language_template(lang).to_string(), + None => DEFAULT_TEMPLATE.to_string(), + } }; let title = module_name diff --git a/src/main.rs b/src/main.rs index 2d744d3..8032529 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod config; mod exports; mod generator; mod hooks; +mod manifest; mod mcp; mod parser; mod registry; @@ -151,6 +152,27 @@ enum HooksAction { } fn main() { + let result = std::panic::catch_unwind(run); + match result { + Ok(()) => {} + Err(e) => { + let msg = if let Some(s) = e.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = e.downcast_ref::() { + s.clone() + } else { + "unknown error".to_string() + }; + eprintln!( + "{} specsync panicked: {msg}\n\nThis is a bug — please report it at https://github.com/CorvidLabs/spec-sync/issues", + "Error:".red().bold() + ); + process::exit(1); + } + } +} + +fn run() { let cli = Cli::parse(); let root = cli .root @@ -161,9 +183,7 @@ fn main() { match command { Command::Init => cmd_init(&root), - Command::Check { fix } => { - cmd_check(&root, cli.strict, cli.require_coverage, cli.json, fix) - } + Command::Check { fix } => cmd_check(&root, cli.strict, cli.require_coverage, cli.json, fix), Command::Coverage => cmd_coverage(&root, cli.strict, cli.require_coverage, cli.json), Command::Generate { provider } => { cmd_generate(&root, cli.strict, cli.require_coverage, cli.json, provider) @@ -278,17 +298,33 @@ fn cmd_init(root: &Path) { } fn cmd_check(root: &Path, strict: bool, require_coverage: Option, json: bool, fix: bool) { - let (config, spec_files) = load_and_discover(root, false); + let (config, spec_files) = load_and_discover(root, fix); + + if spec_files.is_empty() { + if json { + let output = serde_json::json!({ + "passed": true, + "errors": [], + "warnings": [], + "specs_checked": 0, + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } else { + println!( + "No spec files found in {}/. Run `specsync generate` to scaffold specs.", + config.specs_dir + ); + } + process::exit(0); + } + let schema_tables = get_schema_table_names(root, &config); // If --fix is requested, auto-add undocumented exports to specs if fix { let fixed = auto_fix_specs(root, &spec_files, &config); if fixed > 0 && !json { - println!( - "{} Auto-added exports to {fixed} spec(s)\n", - "✓".green() - ); + println!("{} Auto-added exports to {fixed} spec(s)\n", "✓".green()); } } @@ -901,12 +937,8 @@ fn cmd_resolve(root: &Path, remote: bool) { // ─── Auto-fix: add undocumented exports to spec ───────────────────────── -fn auto_fix_specs( - root: &Path, - spec_files: &[PathBuf], - _config: &types::SpecSyncConfig, -) -> usize { - use crate::exports::get_exported_symbols; +fn auto_fix_specs(root: &Path, spec_files: &[PathBuf], config: &types::SpecSyncConfig) -> usize { + use crate::exports::get_exported_symbols_with_level; use crate::parser::{get_spec_symbols, parse_frontmatter}; let mut fixed_count = 0; @@ -930,7 +962,10 @@ fn auto_fix_specs( let mut all_exports: Vec = Vec::new(); for file in &parsed.frontmatter.files { let full_path = root.join(file); - all_exports.extend(get_exported_symbols(&full_path)); + all_exports.extend(get_exported_symbols_with_level( + &full_path, + config.export_level, + )); } let mut seen = std::collections::HashSet::new(); all_exports.retain(|s| seen.insert(s.clone())); @@ -950,10 +985,33 @@ fn auto_fix_specs( continue; } - // Build new rows to append to the Public API section + // Detect primary language for context-aware row format + let primary_lang = parsed + .frontmatter + .files + .iter() + .filter_map(|f| { + std::path::Path::new(f) + .extension() + .and_then(|e| e.to_str()) + .and_then(types::Language::from_extension) + }) + .next(); + + // Build new rows with language-appropriate columns let new_rows: String = undocumented .iter() - .map(|name| format!("| `{name}` | |")) + .map(|name| match primary_lang { + Some(types::Language::Swift) + | Some(types::Language::Kotlin) + | Some(types::Language::Java) => { + format!("| `{name}` | | |") + } + Some(types::Language::Rust) => { + format!("| `{name}` | |") + } + _ => format!("| `{name}` | |"), + }) .collect::>() .join("\n"); @@ -962,9 +1020,7 @@ fn auto_fix_specs( if let Some(api_start) = content.find("## Public API") { let after = &content[api_start..]; // Find the next ## heading after Public API - let next_section = after[1..] - .find("\n## ") - .map(|pos| api_start + 1 + pos); + let next_section = after[1..].find("\n## ").map(|pos| api_start + 1 + pos); let insert_pos = match next_section { Some(pos) => pos, @@ -988,10 +1044,7 @@ fn auto_fix_specs( if let Ok(()) = fs::write(spec_file, &new_content) { fixed_count += 1; - let rel = spec_file - .strip_prefix(root) - .unwrap_or(spec_file) - .display(); + let rel = spec_file.strip_prefix(root).unwrap_or(spec_file).display(); println!( " {} {rel}: added {} export(s)", "✓".green(), diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..1a763a6 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,828 @@ +//! Manifest-aware module detection. +//! +//! Parses language-specific manifest files (Package.swift, Cargo.toml, +//! build.gradle.kts, package.json, etc.) to discover targets, source paths, +//! and module names instead of relying on directory scanning alone. + +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// A module discovered from a manifest file. +#[derive(Debug, Clone)] +pub struct ManifestModule { + /// Module/target name. + pub name: String, + /// Source paths relative to project root. + pub source_paths: Vec, + /// Dependencies (other module names). + pub dependencies: Vec, +} + +/// Result of parsing all manifest files in a project. +#[derive(Debug, Default)] +pub struct ManifestDiscovery { + /// Modules discovered from manifest files, keyed by name. + pub modules: HashMap, + /// Source directories discovered from manifests. + pub source_dirs: Vec, +} + +/// Discover modules from all supported manifest files in the project root. +pub fn discover_from_manifests(root: &Path) -> ManifestDiscovery { + let mut discovery = ManifestDiscovery::default(); + + // Try each manifest type in order + if let Some(d) = parse_cargo_toml(root) { + merge_discovery(&mut discovery, d); + } + if let Some(d) = parse_package_swift(root) { + merge_discovery(&mut discovery, d); + } + if let Some(d) = parse_gradle(root) { + merge_discovery(&mut discovery, d); + } + if let Some(d) = parse_package_json(root) { + merge_discovery(&mut discovery, d); + } + if let Some(d) = parse_pubspec_yaml(root) { + merge_discovery(&mut discovery, d); + } + if let Some(d) = parse_go_mod(root) { + merge_discovery(&mut discovery, d); + } + if let Some(d) = parse_pyproject_toml(root) { + merge_discovery(&mut discovery, d); + } + + discovery +} + +fn merge_discovery(target: &mut ManifestDiscovery, source: ManifestDiscovery) { + for (name, module) in source.modules { + target.modules.entry(name).or_insert(module); + } + for dir in source.source_dirs { + if !target.source_dirs.contains(&dir) { + target.source_dirs.push(dir); + } + } +} + +// ─── Cargo.toml (Rust) ────────────────────────────────────────────────── + +fn parse_cargo_toml(root: &Path) -> Option { + let path = root.join("Cargo.toml"); + let content = fs::read_to_string(&path).ok()?; + let mut discovery = ManifestDiscovery::default(); + + // Extract package name + if let Some(name) = extract_toml_value(&content, "name", Some("[package]")) { + let src_path = "src"; + discovery.modules.insert( + name.clone(), + ManifestModule { + name, + source_paths: vec![src_path.to_string()], + dependencies: Vec::new(), + }, + ); + if !discovery.source_dirs.contains(&src_path.to_string()) { + discovery.source_dirs.push(src_path.to_string()); + } + } + + // Extract [[bin]] targets + for section in split_toml_array_sections(&content, "[[bin]]") { + if let Some(name) = extract_toml_value(§ion, "name", None) { + let path = extract_toml_value(§ion, "path", None) + .unwrap_or_else(|| format!("src/bin/{name}.rs")); + let dir = Path::new(&path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "src".to_string()); + discovery.modules.insert( + name.clone(), + ManifestModule { + name, + source_paths: vec![dir.clone()], + dependencies: Vec::new(), + }, + ); + if !discovery.source_dirs.contains(&dir) { + discovery.source_dirs.push(dir); + } + } + } + + // Check for workspace members + if let Some(members_str) = extract_toml_array(&content, "members", Some("[workspace]")) { + for member in members_str { + // Workspace members are subdirectories with their own Cargo.toml + let member_root = root.join(&member); + if member_root.join("Cargo.toml").exists() { + if let Some(sub) = parse_cargo_toml(&member_root) { + for (_, mut module) in sub.modules { + // Prefix paths with workspace member dir + module.source_paths = module + .source_paths + .iter() + .map(|p| format!("{member}/{p}")) + .collect(); + discovery + .modules + .insert(module.name.clone(), module.clone()); + } + } + if !discovery.source_dirs.contains(&member) { + discovery.source_dirs.push(member); + } + } + } + } + + // Extract [dependencies] as dependency names + if let Some(deps_section) = extract_section(&content, "[dependencies]") { + let dep_names: Vec = deps_section + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('[') { + return None; + } + line.split('=').next().map(|k| k.trim().to_string()) + }) + .filter(|k| !k.is_empty()) + .collect(); + + // Assign deps to the main package + if let Some(pkg_name) = extract_toml_value(&content, "name", Some("[package]")) + && let Some(module) = discovery.modules.get_mut(&pkg_name) + { + module.dependencies = dep_names; + } + } + + if discovery.modules.is_empty() { + None + } else { + Some(discovery) + } +} + +// ─── Package.swift (Swift) ─────────────────────────────────────────────── + +fn parse_package_swift(root: &Path) -> Option { + let path = root.join("Package.swift"); + let content = fs::read_to_string(&path).ok()?; + let mut discovery = ManifestDiscovery::default(); + + // Parse .target and .executableTarget declarations + // Pattern: .target(name: "TargetName", ..., path: "Sources/TargetName", ...) + // or .target(name: "TargetName", dependencies: [...]) + let target_patterns = [ + ".target(", + ".executableTarget(", + ".testTarget(", + ".systemLibrary(", + ]; + + for pattern in &target_patterns { + let is_test = *pattern == ".testTarget("; + let mut search_from = 0; + while let Some(start) = content[search_from..].find(pattern) { + let abs_start = search_from + start; + // Find the matching closing paren (handle nested parens) + if let Some(block) = extract_balanced_parens(&content[abs_start + pattern.len()..]) { + let name = extract_swift_string_param(&block, "name"); + let explicit_path = extract_swift_string_param(&block, "path"); + + if let Some(name) = name + && !is_test + { + let source_path = explicit_path.unwrap_or_else(|| format!("Sources/{name}")); + + discovery.modules.insert( + name.clone(), + ManifestModule { + name: name.clone(), + source_paths: vec![source_path.clone()], + dependencies: extract_swift_dependencies(&block), + }, + ); + + if !discovery.source_dirs.contains(&source_path) { + discovery.source_dirs.push(source_path); + } + } + + search_from = abs_start + pattern.len() + block.len(); + } else { + search_from = abs_start + pattern.len(); + } + } + } + + // Default: if no targets found, check for Sources/ directory + if discovery.modules.is_empty() && root.join("Sources").exists() { + discovery.source_dirs.push("Sources".to_string()); + } + + if discovery.modules.is_empty() && discovery.source_dirs.is_empty() { + None + } else { + Some(discovery) + } +} + +/// Extract the content within balanced parentheses. +fn extract_balanced_parens(s: &str) -> Option { + let mut depth = 1; + let mut end = 0; + for (i, ch) in s.char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = i; + break; + } + } + _ => {} + } + } + if depth == 0 { + Some(s[..end].to_string()) + } else { + None + } +} + +/// Extract a named string parameter from a Swift function call body. +/// e.g. `name: "Foo"` → Some("Foo") +fn extract_swift_string_param(block: &str, param: &str) -> Option { + let pattern = format!("{param}:"); + let start = block.find(&pattern)?; + let after = &block[start + pattern.len()..]; + let quote_start = after.find('"')?; + let rest = &after[quote_start + 1..]; + let quote_end = rest.find('"')?; + Some(rest[..quote_end].to_string()) +} + +/// Extract dependency names from a Swift target block. +fn extract_swift_dependencies(block: &str) -> Vec { + let mut deps = Vec::new(); + if let Some(start) = block.find("dependencies:") { + let after = &block[start..]; + if let Some(bracket_start) = after.find('[') { + let rest = &after[bracket_start + 1..]; + if let Some(bracket_end) = rest.find(']') { + let deps_str = &rest[..bracket_end]; + // Parse both string deps and .target/.product deps + for dep in deps_str.split(',') { + let dep = dep.trim(); + // .target(name: "Foo") or .product(name: "Foo", ...) + if let Some(name) = extract_swift_string_param(dep, "name") { + deps.push(name); + } + // Simple string dependency: "Foo" + else if dep.starts_with('"') && dep.ends_with('"') && dep.len() > 2 { + deps.push(dep[1..dep.len() - 1].to_string()); + } + } + } + } + } + deps +} + +// ─── build.gradle.kts / build.gradle (Kotlin/Java) ────────────────────── + +fn parse_gradle(root: &Path) -> Option { + // Try Kotlin DSL first, then Groovy + let path = if root.join("build.gradle.kts").exists() { + root.join("build.gradle.kts") + } else if root.join("build.gradle").exists() { + root.join("build.gradle") + } else { + return None; + }; + + let content = fs::read_to_string(&path).ok()?; + let mut discovery = ManifestDiscovery::default(); + + // Detect Android project vs plain Kotlin/Java + let is_android = content.contains("android {") || content.contains("android{"); + + if is_android { + // Android: source in app/src/main/java or app/src/main/kotlin + for dir in &[ + "app/src/main/java", + "app/src/main/kotlin", + "src/main/java", + "src/main/kotlin", + ] { + if root.join(dir).exists() { + discovery.source_dirs.push(dir.to_string()); + } + } + } else { + // Standard Gradle: src/main/kotlin or src/main/java + for dir in &["src/main/kotlin", "src/main/java", "src/main/scala"] { + if root.join(dir).exists() { + discovery.source_dirs.push(dir.to_string()); + } + } + } + + // Extract project name from settings.gradle.kts or settings.gradle + let settings_path = if root.join("settings.gradle.kts").exists() { + Some(root.join("settings.gradle.kts")) + } else if root.join("settings.gradle").exists() { + Some(root.join("settings.gradle")) + } else { + None + }; + + if let Some(settings_path) = settings_path + && let Ok(settings) = fs::read_to_string(&settings_path) + { + // Parse include(":module1", ":module2") or include ":module1", ":module2" + for line in settings.lines() { + let line = line.trim(); + if line.starts_with("include") { + // Extract quoted module names + let mut search = line; + while let Some(quote_start) = search.find('"') { + let rest = &search[quote_start + 1..]; + if let Some(quote_end) = rest.find('"') { + let module = rest[..quote_end].trim_start_matches(':'); + if !module.is_empty() { + let module_src = format!("{module}/src/main"); + let source_path = + if root.join(format!("{module}/src/main/kotlin")).exists() { + format!("{module}/src/main/kotlin") + } else if root.join(format!("{module}/src/main/java")).exists() { + format!("{module}/src/main/java") + } else { + module_src + }; + + discovery.modules.insert( + module.to_string(), + ManifestModule { + name: module.to_string(), + source_paths: vec![source_path.clone()], + dependencies: Vec::new(), + }, + ); + if !discovery.source_dirs.contains(&source_path) { + discovery.source_dirs.push(source_path); + } + } + search = &rest[quote_end + 1..]; + } else { + break; + } + } + } + } + } + + if discovery.modules.is_empty() && discovery.source_dirs.is_empty() { + None + } else { + Some(discovery) + } +} + +// ─── package.json (TypeScript/JavaScript) ──────────────────────────────── + +fn parse_package_json(root: &Path) -> Option { + let path = root.join("package.json"); + let content = fs::read_to_string(&path).ok()?; + let json: serde_json::Value = serde_json::from_str(&content).ok()?; + let mut discovery = ManifestDiscovery::default(); + + let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("app"); + + // Check for workspaces (monorepo) + if let Some(workspaces) = json.get("workspaces") { + let workspace_patterns: Vec<&str> = match workspaces { + serde_json::Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect(), + serde_json::Value::Object(obj) => { + if let Some(serde_json::Value::Array(arr)) = obj.get("packages") { + arr.iter().filter_map(|v| v.as_str()).collect() + } else { + Vec::new() + } + } + _ => Vec::new(), + }; + + for pattern in workspace_patterns { + // Simple glob: "packages/*" → look for subdirs + let base = pattern.trim_end_matches("/*").trim_end_matches("/**"); + let base_dir = root.join(base); + if base_dir.exists() + && base_dir.is_dir() + && let Ok(entries) = fs::read_dir(&base_dir) + { + for entry in entries.flatten() { + if entry.path().is_dir() { + let pkg_json = entry.path().join("package.json"); + if pkg_json.exists() { + let ws_name = entry.file_name().to_string_lossy().to_string(); + let src_dir = if entry.path().join("src").exists() { + format!("{base}/{ws_name}/src") + } else { + format!("{base}/{ws_name}") + }; + discovery.modules.insert( + ws_name.clone(), + ManifestModule { + name: ws_name, + source_paths: vec![src_dir.clone()], + dependencies: Vec::new(), + }, + ); + if !discovery.source_dirs.contains(&src_dir) { + discovery.source_dirs.push(src_dir); + } + } + } + } + } + } + } + + // Detect main source directory + let main_field = json.get("main").and_then(|v| v.as_str()).unwrap_or(""); + let src_dir = if root.join("src").exists() { + "src" + } else if root.join("lib").exists() { + "lib" + } else if main_field.starts_with("./") { + Path::new(main_field) + .parent() + .and_then(|p| p.to_str()) + .unwrap_or("src") + } else { + "src" + }; + + if discovery.modules.is_empty() { + discovery.modules.insert( + name.to_string(), + ManifestModule { + name: name.to_string(), + source_paths: vec![src_dir.to_string()], + dependencies: Vec::new(), + }, + ); + } + + if !discovery.source_dirs.contains(&src_dir.to_string()) { + discovery.source_dirs.push(src_dir.to_string()); + } + + Some(discovery) +} + +// ─── pubspec.yaml (Dart/Flutter) ───────────────────────────────────────── + +fn parse_pubspec_yaml(root: &Path) -> Option { + let path = root.join("pubspec.yaml"); + let content = fs::read_to_string(&path).ok()?; + let mut discovery = ManifestDiscovery::default(); + + // Extract name from `name: my_package` + let name = content + .lines() + .find(|l| l.starts_with("name:")) + .and_then(|l| l.strip_prefix("name:")) + .map(|n| n.trim().to_string()) + .unwrap_or_else(|| "app".to_string()); + + let src_dir = "lib"; + + discovery.modules.insert( + name.clone(), + ManifestModule { + name, + source_paths: vec![src_dir.to_string()], + dependencies: Vec::new(), + }, + ); + discovery.source_dirs.push(src_dir.to_string()); + + Some(discovery) +} + +// ─── go.mod (Go) ───────────────────────────────────────────────────────── + +fn parse_go_mod(root: &Path) -> Option { + let path = root.join("go.mod"); + let content = fs::read_to_string(&path).ok()?; + let mut discovery = ManifestDiscovery::default(); + + // Extract module name: `module github.com/user/repo` + let module_name = content + .lines() + .find(|l| l.starts_with("module ")) + .and_then(|l| l.strip_prefix("module ")) + .map(|m| { + // Use last segment as module name + m.trim().rsplit('/').next().unwrap_or(m.trim()).to_string() + }) + .unwrap_or_else(|| "app".to_string()); + + // Go projects: scan for directories with .go files as packages + // Common patterns: cmd/, internal/, pkg/ + let mut source_dirs = Vec::new(); + for dir_name in &["cmd", "internal", "pkg", "api"] { + if root.join(dir_name).exists() { + source_dirs.push(dir_name.to_string()); + } + } + + // If none of the standard dirs exist, use "." (root) + if source_dirs.is_empty() { + source_dirs.push(".".to_string()); + } + + discovery.modules.insert( + module_name.clone(), + ManifestModule { + name: module_name, + source_paths: source_dirs.clone(), + dependencies: Vec::new(), + }, + ); + discovery.source_dirs = source_dirs; + + Some(discovery) +} + +// ─── pyproject.toml (Python) ───────────────────────────────────────────── + +fn parse_pyproject_toml(root: &Path) -> Option { + let path = root.join("pyproject.toml"); + let content = fs::read_to_string(&path).ok()?; + let mut discovery = ManifestDiscovery::default(); + + // Try [project] name first, then [tool.poetry] name + let name = extract_toml_value(&content, "name", Some("[project]")) + .or_else(|| extract_toml_value(&content, "name", Some("[tool.poetry]"))) + .unwrap_or_else(|| "app".to_string()); + + // Check for packages in [tool.setuptools.packages.find] + let src_dir = if root.join("src").exists() { + "src".to_string() + } else if root.join(&name).exists() { + name.clone() + } else { + ".".to_string() + }; + + discovery.modules.insert( + name.clone(), + ManifestModule { + name, + source_paths: vec![src_dir.to_string()], + dependencies: Vec::new(), + }, + ); + discovery.source_dirs.push(src_dir.to_string()); + + Some(discovery) +} + +// ─── TOML Helpers ──────────────────────────────────────────────────────── + +/// Extract a string value from a TOML key, optionally within a specific section. +fn extract_toml_value(content: &str, key: &str, section: Option<&str>) -> Option { + let search_content = if let Some(section_header) = section { + extract_section(content, section_header)? + } else { + content.to_string() + }; + + for line in search_content.lines() { + let line = line.trim(); + if let Some(eq_pos) = line.find('=') { + let k = line[..eq_pos].trim(); + if k == key { + let val = line[eq_pos + 1..].trim(); + // Strip quotes + if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 { + return Some(val[1..val.len() - 1].to_string()); + } + return Some(val.to_string()); + } + } + } + None +} + +/// Extract an array of strings from a TOML key within a section. +fn extract_toml_array(content: &str, key: &str, section: Option<&str>) -> Option> { + let search_content = if let Some(section_header) = section { + extract_section(content, section_header)? + } else { + content.to_string() + }; + + for line in search_content.lines() { + let line = line.trim(); + if let Some(eq_pos) = line.find('=') { + let k = line[..eq_pos].trim(); + if k == key { + let val = line[eq_pos + 1..].trim(); + if val.starts_with('[') && val.ends_with(']') { + let inner = &val[1..val.len() - 1]; + let items: Vec = inner + .split(',') + .map(|s| { + let s = s.trim(); + if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } + }) + .filter(|s| !s.is_empty()) + .collect(); + return Some(items); + } + } + } + } + None +} + +/// Extract the content of a TOML section (from header to next section or EOF). +fn extract_section(content: &str, header: &str) -> Option { + let start = content.find(header)?; + let after = &content[start + header.len()..]; + // Find the next section header + let end = after.find("\n[").map(|pos| pos + 1).unwrap_or(after.len()); + Some(after[..end].to_string()) +} + +/// Split TOML content into repeated array-of-table sections (e.g., [[bin]]). +fn split_toml_array_sections(content: &str, header: &str) -> Vec { + let mut sections = Vec::new(); + let mut search_from = 0; + + while let Some(start) = content[search_from..].find(header) { + let abs_start = search_from + start + header.len(); + let rest = &content[abs_start..]; + + // Find end: next [[...]] or [...] section + let end = rest + .find("\n[[") + .or_else(|| rest.find("\n[")) + .map(|pos| pos + 1) + .unwrap_or(rest.len()); + + sections.push(rest[..end].to_string()); + search_from = abs_start + end; + } + + sections +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_parse_cargo_toml_basic() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("src")).unwrap(); + fs::write(tmp.path().join("src/lib.rs"), "").unwrap(); + fs::write( + tmp.path().join("Cargo.toml"), + r#" +[package] +name = "my-crate" +version = "0.1.0" + +[dependencies] +serde = "1.0" +regex = "1.0" +"#, + ) + .unwrap(); + + let result = parse_cargo_toml(tmp.path()).unwrap(); + assert!(result.modules.contains_key("my-crate")); + let module = &result.modules["my-crate"]; + assert_eq!(module.source_paths, vec!["src"]); + assert!(module.dependencies.contains(&"serde".to_string())); + assert!(module.dependencies.contains(&"regex".to_string())); + } + + #[test] + fn test_parse_package_swift_basic() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("Sources/MyLib")).unwrap(); + fs::write( + tmp.path().join("Package.swift"), + r#" +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "MyPackage", + targets: [ + .target(name: "MyLib", dependencies: ["Logging"]), + .target(name: "MyApp", dependencies: [.target(name: "MyLib")], path: "Sources/App"), + .testTarget(name: "MyLibTests", dependencies: ["MyLib"]), + ] +) +"#, + ) + .unwrap(); + + let result = parse_package_swift(tmp.path()).unwrap(); + assert!(result.modules.contains_key("MyLib")); + assert!(result.modules.contains_key("MyApp")); + // testTarget should NOT be in modules + assert!(!result.modules.contains_key("MyLibTests")); + + let mylib = &result.modules["MyLib"]; + assert_eq!(mylib.source_paths, vec!["Sources/MyLib"]); + assert!(mylib.dependencies.contains(&"Logging".to_string())); + + let myapp = &result.modules["MyApp"]; + assert_eq!(myapp.source_paths, vec!["Sources/App"]); + } + + #[test] + fn test_parse_package_json_workspaces() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("packages/core/src")).unwrap(); + fs::create_dir_all(tmp.path().join("packages/web/src")).unwrap(); + fs::write( + tmp.path().join("packages/core/package.json"), + r#"{"name": "@app/core"}"#, + ) + .unwrap(); + fs::write( + tmp.path().join("packages/web/package.json"), + r#"{"name": "@app/web"}"#, + ) + .unwrap(); + fs::create_dir_all(tmp.path().join("src")).unwrap(); + fs::write( + tmp.path().join("package.json"), + r#"{"name": "my-app", "workspaces": ["packages/*"]}"#, + ) + .unwrap(); + + let result = parse_package_json(tmp.path()).unwrap(); + assert!(result.modules.contains_key("core")); + assert!(result.modules.contains_key("web")); + assert!( + result + .source_dirs + .contains(&"packages/core/src".to_string()) + ); + } + + #[test] + fn test_parse_go_mod() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("cmd")).unwrap(); + fs::create_dir_all(tmp.path().join("internal")).unwrap(); + fs::write( + tmp.path().join("go.mod"), + "module github.com/user/myproject\n\ngo 1.21\n", + ) + .unwrap(); + + let result = parse_go_mod(tmp.path()).unwrap(); + assert!(result.modules.contains_key("myproject")); + assert!(result.source_dirs.contains(&"cmd".to_string())); + assert!(result.source_dirs.contains(&"internal".to_string())); + } + + #[test] + fn test_extract_balanced_parens() { + assert_eq!( + extract_balanced_parens("name: \"Foo\", path: \"bar\")"), + Some("name: \"Foo\", path: \"bar\"".to_string()) + ); + assert_eq!( + extract_balanced_parens("a(b), c)"), + Some("a(b), c".to_string()) + ); + assert_eq!(extract_balanced_parens("no close paren"), None); + } +} diff --git a/src/types.rs b/src/types.rs index 813e77e..4104c00 100644 --- a/src/types.rs +++ b/src/types.rs @@ -155,6 +155,19 @@ pub struct CoverageReport { pub unspecced_file_loc: Vec<(String, usize)>, } +/// Controls export extraction granularity. +/// - `type`: Only top-level type declarations (class, struct, enum, protocol, trait, etc.) +/// - `member`: Every public symbol including members (functions, properties, etc.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ExportLevel { + /// Only top-level type declarations (class, struct, enum, protocol, trait, etc.) + Type, + /// Every public symbol including members (default for backwards compatibility). + #[default] + Member, +} + /// User-provided configuration (from specsync.json). #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -181,6 +194,16 @@ pub struct SpecSyncConfig { #[serde(default)] pub source_extensions: Vec, + /// Export granularity: "type" (top-level types only) or "member" (all public symbols). + /// Default: "member" for backwards compatibility. + #[serde(default)] + pub export_level: ExportLevel, + + /// Module definitions — override auto-detected modules with explicit groupings. + /// Keys are module names, values are objects with `files` and optional `depends_on`. + #[serde(default)] + pub modules: std::collections::HashMap, + /// AI provider preset: "claude", "cursor", "copilot", "ollama". /// Resolves to the correct CLI command automatically. #[serde(default)] @@ -210,6 +233,19 @@ pub struct SpecSyncConfig { pub ai_timeout: Option, } +/// A user-defined module grouping in specsync.json. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct ModuleDefinition { + /// Source files belonging to this module (relative to project root). + #[serde(default)] + pub files: Vec, + /// Other module names this module depends on. + #[serde(default)] + pub depends_on: Vec, +} + /// Registry entry mapping module names to spec file paths. /// Used in `specsync-registry.toml` for cross-project resolution. #[derive(Debug, Clone)] @@ -273,10 +309,27 @@ impl Language { Language::Rust => &[], // Rust tests are inline, not separate files Language::Go => &["_test.go"], Language::Python => &["test_", "_test.py"], - Language::Swift => &["Tests.swift", "Test.swift"], - Language::Kotlin => &["Test.kt", "Tests.kt", "Spec.kt"], - Language::Java => &["Test.java", "Tests.java"], - Language::CSharp => &["Tests.cs", "Test.cs"], + Language::Swift => &[ + "Tests.swift", + "Test.swift", + "Spec.swift", + "Specs.swift", + "Mock.swift", + "Mocks.swift", + "Stub.swift", + "Fake.swift", + ], + Language::Kotlin => &[ + "Test.kt", "Tests.kt", "Spec.kt", "Specs.kt", "Mock.kt", "Fake.kt", + ], + Language::Java => &[ + "Test.java", + "Tests.java", + "Spec.java", + "Mock.java", + "IT.java", + ], + Language::CSharp => &["Tests.cs", "Test.cs", "Spec.cs", "Mock.cs"], Language::Dart => &["_test.dart"], } } @@ -327,6 +380,8 @@ impl Default for SpecSyncConfig { exclude_dirs: default_exclude_dirs(), exclude_patterns: default_exclude_patterns(), source_extensions: Vec::new(), + export_level: ExportLevel::default(), + modules: std::collections::HashMap::new(), ai_provider: None, ai_model: None, ai_command: None, diff --git a/src/validator.rs b/src/validator.rs index c02cd16..caf9d43 100644 --- a/src/validator.rs +++ b/src/validator.rs @@ -1,5 +1,5 @@ -use crate::config::default_schema_pattern; -use crate::exports::{get_exported_symbols, has_extension, is_test_file}; +use crate::config::{default_schema_pattern, discover_manifest_modules}; +use crate::exports::{get_exported_symbols_with_level, has_extension, is_test_file}; use crate::parser::{get_missing_sections, get_spec_symbols, parse_frontmatter}; use crate::types::{CoverageReport, SpecSyncConfig, ValidationResult}; use regex::Regex; @@ -248,7 +248,7 @@ pub fn validate_spec( let mut all_exports: Vec = Vec::new(); for file in &fm.files { let full_path = root.join(file); - let exports = get_exported_symbols(&full_path); + let exports = get_exported_symbols_with_level(&full_path, config.export_level); all_exports.extend(exports); } @@ -658,6 +658,23 @@ pub fn compute_coverage( let mut unspecced_modules = Vec::new(); let mut seen_modules: HashSet = HashSet::new(); + // User-defined modules from specsync.json take priority + if !config.modules.is_empty() { + for name in config.modules.keys() { + if !spec_modules.contains(name) && seen_modules.insert(name.clone()) { + unspecced_modules.push(name.clone()); + } + } + } + + // Then: detect modules from manifest files (Package.swift, Cargo.toml, etc.) + let manifest = discover_manifest_modules(root); + for name in manifest.modules.keys() { + if !spec_modules.contains(name) && seen_modules.insert(name.clone()) { + unspecced_modules.push(name.clone()); + } + } + // Detect subdirectory-based modules for src_dir in &config.source_dirs { let full_dir = root.join(src_dir); diff --git a/tests/integration.rs b/tests/integration.rs index 5bcbb89..f7beb3e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2292,13 +2292,7 @@ fn fix_with_json_output() { // --fix with --json should still work and produce valid JSON let output = specsync() - .args([ - "check", - "--fix", - "--json", - "--root", - root.to_str().unwrap(), - ]) + .args(["check", "--fix", "--json", "--root", root.to_str().unwrap()]) .output() .unwrap(); @@ -2380,7 +2374,14 @@ fn diff_shows_changes_since_base_ref() { // Run diff with --json let output = specsync() - .args(["diff", "--base", "HEAD", "--root", root.to_str().unwrap(), "--json"]) + .args([ + "diff", + "--base", + "HEAD", + "--root", + root.to_str().unwrap(), + "--json", + ]) .output() .unwrap(); @@ -2389,10 +2390,7 @@ fn diff_shows_changes_since_base_ref() { let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); let changes = json["changes"].as_array().unwrap(); - assert!( - !changes.is_empty(), - "Expected at least one changed spec" - ); + assert!(!changes.is_empty(), "Expected at least one changed spec"); assert!( changes[0]["new_exports"] .as_array() @@ -2455,7 +2453,14 @@ fn diff_no_changes_returns_empty() { // Run diff — nothing changed since HEAD let output = specsync() - .args(["diff", "--base", "HEAD", "--root", root.to_str().unwrap(), "--json"]) + .args([ + "diff", + "--base", + "HEAD", + "--root", + root.to_str().unwrap(), + "--json", + ]) .output() .unwrap(); @@ -2571,7 +2576,14 @@ None .unwrap(); let output = specsync() - .args(["diff", "--base", "HEAD", "--root", root.to_str().unwrap(), "--json"]) + .args([ + "diff", + "--base", + "HEAD", + "--root", + root.to_str().unwrap(), + "--json", + ]) .output() .unwrap(); @@ -2705,9 +2717,7 @@ fn wildcard_reexport_barrel_file_detected() { let stdout = String::from_utf8_lossy(&output.stdout); // The check should find undocumented exports from the barrel file assert!( - stdout.contains("formatDate") - || stdout.contains("parseUrl") - || stdout.contains("utilMain"), + stdout.contains("formatDate") || stdout.contains("parseUrl") || stdout.contains("utilMain"), "Expected check to detect wildcard re-exported symbols. Got:\n{stdout}" ); } @@ -2753,10 +2763,7 @@ fn wildcard_reexport_with_fix_adds_all_symbols() { updated.contains("`helperB`"), "Expected helperB from wildcard re-export" ); - assert!( - updated.contains("`main`"), - "Expected main direct export" - ); + assert!(updated.contains("`main`"), "Expected main direct export"); } #[test]