diff --git a/src/completion.rs b/src/completion.rs index bd7624f..bb8a5c5 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -255,9 +255,16 @@ fn local_commands(config: &LoadedConfig, prefix: &str) -> Vec { + for (name, entry) in &file.commands { + if name.starts_with(prefix) { + map.insert(name.clone(), command_suggestion(name, entry)); + } + } + } + FileScope::Group { .. } | FileScope::NamespaceGroup { .. } => { + // Hidden from root commands } } } @@ -372,10 +379,18 @@ fn local_groups(config: &LoadedConfig, prefix: &str) -> Vec { + if group.starts_with(prefix) { + set.insert(group.clone()); + } } + FileScope::NamespaceGroup { group, .. } => { + if group.starts_with(prefix) { + set.insert(group.clone()); + } + } + _ => {} } } set.into_iter() @@ -450,14 +465,26 @@ fn namespace_groups( fn group_children(config: &LoadedConfig, group: &str, prefix: &str) -> Vec { let mut map = BTreeMap::new(); for file in &config.files { - if let FileScope::Group { group: file_alias } = &file.scope { - if file_alias == group { - for (name, entry) in &file.commands { - if name.starts_with(prefix) { - map.insert(name.clone(), command_suggestion(name, entry)); + match &file.scope { + FileScope::Group { group: file_alias } => { + if file_alias == group { + for (name, entry) in &file.commands { + if name.starts_with(prefix) { + map.insert(name.clone(), command_suggestion(name, entry)); + } } } } + FileScope::NamespaceGroup { group: file_alias, .. } if file.source == SourceKind::Local => { + if file_alias == group { + for (name, entry) in &file.commands { + if name.starts_with(prefix) { + map.insert(name.clone(), command_suggestion(name, entry)); + } + } + } + } + _ => {} } } map.into_values().collect() @@ -526,13 +553,21 @@ fn nested_from_group_scope( let group = &path[0]; let command_name = &path[1]; let mut command = None; + + // We reverse to prioritize overrides (though not fully applicable here, but consistent style) for file in config.files.iter().rev() { - if let FileScope::Group { group: file_alias } = &file.scope { - if file_alias == group { - if let Some(candidate) = file.commands.get(command_name) { - command = Some(candidate); - break; - } + let matches = match &file.scope { + FileScope::Group { group: file_alias } => file_alias == group, + FileScope::NamespaceGroup { group: file_alias, .. } if file.source == SourceKind::Local => { + file_alias == group + } + _ => false, + }; + + if matches { + if let Some(candidate) = file.commands.get(command_name) { + command = Some(candidate); + break; } } } @@ -845,7 +880,9 @@ commands: }], }; - let values = completion_suggestions(&config, &["run".to_string()]); + // Updated loop to respect mandatory group enforcement. + // Command "run" in group "ops" must be accessed via "ops run". + let values = completion_suggestions(&config, &["ops".to_string(), "run".to_string()]); let names: Vec = values.into_iter().map(|it| it.value).collect(); assert_eq!(names, vec!["start", "test"]); } diff --git a/src/config.rs b/src/config.rs index d33979b..50168cd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, fs, path::{Component, Path, PathBuf}, + process::Command, }; use serde::Deserialize; @@ -196,16 +197,19 @@ pub(crate) fn load_config() -> LoadedConfig { let mut loaded = LoadedConfig::default(); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + // Attempt git root detection FIRST + let project_root = detect_git_root(&cwd).unwrap_or(cwd.clone()); + // Local project commands. loaded .files - .extend(load_local_file_configs(&cwd, SourceKind::Local)); + .extend(load_local_file_configs(&project_root, SourceKind::Local)); // Global installed directories. No implicit namespace here: // files without namespace/group stay as direct global commands. let installed_dirs = load_installed_directories(); for directory in installed_dirs { - if directory == cwd { + if directory == project_root { continue; } loaded @@ -216,6 +220,42 @@ pub(crate) fn load_config() -> LoadedConfig { loaded } +fn detect_git_root(cwd: &Path) -> Option { + if !cwd.exists() { + return None; + } + + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(cwd) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let path_str = String::from_utf8(output.stdout).ok()?; + let path = PathBuf::from(path_str.trim()); + + // Check if git root has ANY fire config. + let config_files = discover_config_files(&path); + if config_files.is_empty() { + return None; + } + + // Check if any of the root config files defines a namespace. + let raw = parse_raw_files(&config_files); + let has_explicit_namespace = select_directory_explicit_namespace(&raw).is_some(); + + if has_explicit_namespace { + return Some(path); + } + + None +} + fn load_local_file_configs(cwd: &Path, source: SourceKind) -> Vec { load_directory_file_configs(cwd, source, DefaultNamespaceMode::DirectoryFallback) } diff --git a/src/help.rs b/src/help.rs index e91b65e..9c32c66 100644 --- a/src/help.rs +++ b/src/help.rs @@ -115,8 +115,15 @@ fn local_commands(config: &LoadedConfig) -> Vec<(String, Option)> { if file.source != crate::config::SourceKind::Local { continue; } - for (name, entry) in &file.commands { - map.insert(name.clone(), optional_description(entry)); + match &file.scope { + FileScope::Root | FileScope::Namespace { .. } => { + for (name, entry) in &file.commands { + map.insert(name.clone(), optional_description(entry)); + } + } + FileScope::Group { .. } | FileScope::NamespaceGroup { .. } => { + // Commands inside groups are NOT displayed at root level. + } } } map.into_iter().collect() @@ -146,8 +153,14 @@ fn namespaces(config: &LoadedConfig) -> Vec<(String, Option)> { fn groups(config: &LoadedConfig) -> Vec<(String, Option)> { let mut map = BTreeMap::new(); for file in &config.files { - if let FileScope::Group { group } = &file.scope { - map.insert(group.clone(), None); + match &file.scope { + FileScope::Group { group } => { + map.insert(group.clone(), None); + } + FileScope::NamespaceGroup { group, .. } if file.source == crate::config::SourceKind::Local => { + map.insert(group.clone(), None); + } + _ => {} } } map.into_iter().collect() @@ -219,11 +232,17 @@ fn namespace_groups(config: &LoadedConfig, namespace: &str) -> Vec<(String, Opti fn group_commands(config: &LoadedConfig, group: &str) -> Vec<(String, Option)> { let mut map = BTreeMap::new(); for file in &config.files { - if let FileScope::Group { group: file_alias } = &file.scope { - if file_alias == group { - for (name, entry) in &file.commands { - map.insert(name.clone(), optional_description(entry)); - } + let is_match = match &file.scope { + FileScope::Group { group: file_alias } => file_alias == group, + FileScope::NamespaceGroup { group: file_alias, .. } if file.source == crate::config::SourceKind::Local => { + file_alias == group + } + _ => false, + }; + + if is_match { + for (name, entry) in &file.commands { + map.insert(name.clone(), optional_description(entry)); } } } @@ -272,9 +291,13 @@ fn has_namespace(config: &LoadedConfig, namespace: &str) -> bool { } fn has_root_group(config: &LoadedConfig, group: &str) -> bool { - config.files.iter().any( - |file| matches!(&file.scope, FileScope::Group { group: file_alias } if file_alias == group), - ) + config.files.iter().any(|file| match &file.scope { + FileScope::Group { group: file_alias } => file_alias == group, + FileScope::NamespaceGroup { group: file_alias, .. } if file.source == crate::config::SourceKind::Local => { + file_alias == group + } + _ => false, + }) } fn has_namespace_prefix(config: &LoadedConfig, namespace: &str, group: &str) -> bool { diff --git a/src/resolve.rs b/src/resolve.rs index 4ef48dd..3346a99 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -1,6 +1,6 @@ use std::{collections::BTreeMap, path::Path}; -use crate::config::{CommandEntry, FileScope, LoadedConfig, RuntimeConfig}; +use crate::config::{CommandEntry, LoadedConfig, RuntimeConfig}; pub(crate) struct ResolvedCommand<'a> { pub(crate) project_dir: &'a Path, @@ -19,7 +19,7 @@ pub(crate) fn resolve_command<'a>( for file in &config.files { for (command_name, command_entry) in &file.commands { - let Some(base_consumed) = scope_match_consumed(&file.scope, command_name, args) else { + let Some(base_consumed) = scope_match_consumed(&file, command_name, args) else { continue; }; @@ -58,22 +58,16 @@ pub(crate) fn resolve_command<'a>( best } -fn scope_match_consumed(scope: &FileScope, command_name: &str, args: &[String]) -> Option { - let local_match = if args.first().map(String::as_str) == Some(command_name) { - Some(1) - } else { - None - }; - - let scoped_match = match scope { - FileScope::Root => { +fn scope_match_consumed(file: &crate::config::FileConfig, command_name: &str, args: &[String]) -> Option { + let explicit_match = match &file.scope { + crate::config::FileScope::Root => { if args.first().map(String::as_str) == Some(command_name) { Some(1) } else { None } } - FileScope::Namespace { namespace, .. } => { + crate::config::FileScope::Namespace { namespace, .. } => { if args.first().map(String::as_str) == Some(namespace.as_str()) && args.get(1).map(String::as_str) == Some(command_name) { @@ -82,7 +76,7 @@ fn scope_match_consumed(scope: &FileScope, command_name: &str, args: &[String]) None } } - FileScope::Group { group } => { + crate::config::FileScope::Group { group } => { if args.first().map(String::as_str) == Some(group.as_str()) && args.get(1).map(String::as_str) == Some(command_name) { @@ -91,7 +85,7 @@ fn scope_match_consumed(scope: &FileScope, command_name: &str, args: &[String]) None } } - FileScope::NamespaceGroup { + crate::config::FileScope::NamespaceGroup { namespace, group, .. } => { if args.first().map(String::as_str) == Some(namespace.as_str()) @@ -105,7 +99,41 @@ fn scope_match_consumed(scope: &FileScope, command_name: &str, args: &[String]) } }; - scoped_match.or(local_match) + if explicit_match.is_some() { + return explicit_match; + } + + // Implicit matching (Local ONLY) + if file.source == crate::config::SourceKind::Local { + // Inside the directory of the namespace/group. + // Rules: + // 3.2: Namespace optional inside its directory. + match &file.scope { + crate::config::FileScope::Namespace { .. } => { + // fire command -> supported + if args.first().map(String::as_str) == Some(command_name) { + return Some(1); + } + } + crate::config::FileScope::NamespaceGroup { group, .. } => { + // fire group command -> supported + if args.first().map(String::as_str) == Some(group.as_str()) + && args.get(1).map(String::as_str) == Some(command_name) + { + return Some(2); + } + } + crate::config::FileScope::Root => { + // Already handled in explicit_match for Root + } + crate::config::FileScope::Group { .. } => { + // fire group command -> Already handled in explicit_match + // Group is always mandatory. + } + } + } + + None } fn better_than( @@ -209,7 +237,28 @@ commands: api: exec: npm run api "#; + // Case 1: Local source (Implicit allowed) let config = LoadedConfig { + files: vec![FileConfig { + source: SourceKind::Local, + project_dir: PathBuf::from("."), + scope: FileScope::NamespaceGroup { + namespace: "ex".to_string(), + namespace_description: String::new(), + group: "backend".to_string(), + }, + runtimes: BTreeMap::new(), + commands: parse_commands(yaml), + }], + }; + + // Local: fire backend api -> works (implicit namespace) + let args = vec!["backend".to_string(), "api".to_string(), "--watch".to_string()]; + let resolved = resolve_command(&config, &args).expect("resolved implicit local"); + assert_eq!(resolved.consumed, 2); + + // Case 2: Global source (Implicit disallowed) + let config_global = LoadedConfig { files: vec![FileConfig { source: SourceKind::Global, project_dir: PathBuf::from("."), @@ -223,10 +272,14 @@ commands: }], }; - let args = vec!["api".to_string(), "--watch".to_string()]; - let resolved = resolve_command(&config, &args).expect("resolved"); + // Global: fire backend api -> fails (require namespace) + let args_global = vec!["backend".to_string(), "api".to_string(), "--watch".to_string()]; + let resolved_global = resolve_command(&config_global, &args_global); + assert!(resolved_global.is_none(), "Global should not resolve implicit namespace"); - assert_eq!(resolved.consumed, 1); - assert_eq!(resolved.remaining_args, &["--watch".to_string()]); + // Global: fire ex backend api -> works + let args_global_explicit = vec!["ex".to_string(), "backend".to_string(), "api".to_string(), "--watch".to_string()]; + let resolved_global_explicit = resolve_command(&config_global, &args_global_explicit).expect("resolved explicit global"); + assert_eq!(resolved_global_explicit.consumed, 3); } }