From 2da4599f13ee2f99fba1fb532bf59758596fb116 Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Sat, 28 Feb 2026 17:35:14 -0600 Subject: [PATCH 01/10] namespaces and groups, install globally --- README.md | 59 +++ config.sample.fire.yml | 9 +- config.simple.config.fire.yml | 4 + schemas/fire.schema.json | 12 +- src/completion.rs | 795 +++++++++++++++++++++++++++++----- src/config.rs | 248 ++++++++++- src/execute.rs | 13 +- src/help.rs | 390 +++++++++++++++-- src/lib.rs | 92 ++-- src/registry.rs | 80 ++++ src/resolve.rs | 232 +++++++--- 11 files changed, 1679 insertions(+), 255 deletions(-) create mode 100644 config.simple.config.fire.yml create mode 100644 src/registry.rs diff --git a/README.md b/README.md index e90be5f..9bb3e28 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ If the same command name appears more than once, the last loaded definition wins Example: ```yaml +group: backend +namespace: + prefix: ex + description: Example + commands: run: description: Run npm scripts @@ -33,6 +38,48 @@ commands: `exec` and `run` are both supported and treated as executable command actions. +Default namespace behavior: +- If `namespace.prefix` is omitted in a file, Fire inherits it from another file in the same directory that defines it. +- If no file in that directory defines `namespace.prefix`, the file remains outside namespace scope. + +## Command Resolution Rules + +Fire resolves commands by file scope: + +1. No `namespace.prefix`, no `group`: +- `fire ` (local direct command) +- `fire ` (namespace path) + +2. With `namespace.prefix`, no global `group`: +- `fire ` + +3. With global `group`, no `namespace`: +- `fire ` + +4. With `namespace` and global `group`: +- `fire ` + +Root completion priority (`fire `) is: +1. Root-local commands +2. Global namespaces +3. Global groups without namespace +4. Global direct commands + +## Global Installation + +`fire cli` is a reserved internal command. + +Install the current directory globally: + +```bash +fire cli install +``` + +Behavior: +- Stores only the absolute directory path (no command cache, no file copy). +- Avoids duplicates if the path is already installed. +- On each run, Fire dynamically reads installed directories and loads fire files from each directory root (non-recursive). + ## `config.fire.yaml` Validation in VS Code The schema is available at [`schemas/fire.schema.json`](./schemas/fire.schema.json). @@ -101,6 +148,18 @@ Example: `cli` is reserved for internal CLI management and cannot be overridden by user commands. +## Help Suffix + +Append `:h` to any resolved command path to show help without execution: + +- `fire run :h` +- `fire ex backend api :h` + +It prints: +- Command path +- Command `description` (if any) +- Subcommands and their descriptions (if any) + ## Run ```bash cargo build diff --git a/config.sample.fire.yml b/config.sample.fire.yml index a4a8b14..c127988 100644 --- a/config.sample.fire.yml +++ b/config.sample.fire.yml @@ -5,8 +5,10 @@ env_file: .env # default env file namespace: id: 51170376-ea38-4317-ba49-4e63dfc8a571 # optional - description: Pumpkat - alias: pk + description: Example + prefix: ex + +group: backend runtimes: deno: @@ -57,6 +59,9 @@ commands: fallback_runner: docker run --rm -it node:lts-alpine /bin/bash # fallback exec: npm + docker-node: + exec: docker run --rm -it node:lts-alpine sh + run: description: | y si agrego descripción a este? diff --git a/config.simple.config.fire.yml b/config.simple.config.fire.yml new file mode 100644 index 0000000..0310600 --- /dev/null +++ b/config.simple.config.fire.yml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=./schemas/fire.schema.json + +commands: + say-hello: echo hello!! diff --git a/schemas/fire.schema.json b/schemas/fire.schema.json index e063197..00d77c4 100644 --- a/schemas/fire.schema.json +++ b/schemas/fire.schema.json @@ -16,6 +16,10 @@ "namespace": { "$ref": "#/$defs/namespace" }, + "group": { + "type": "string", + "description": "Optional file-level group used as a command prefix for all commands in this file." + }, "runtimes": { "type": "object", "description": "Runtime definitions keyed by alias (for example: deno, py).", @@ -66,14 +70,14 @@ "type": "string", "description": "Human-readable namespace label." }, - "alias": { + "prefix": { "type": "string", - "description": "Short namespace alias." + "description": "Namespace prefix used in CLI paths." } }, "required": [ - "description", - "alias" + "prefix", + "description" ] }, "runtime": { diff --git a/src/completion.rs b/src/completion.rs index 0739cf8..1470efa 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -1,6 +1,6 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use crate::config::{CommandEntry, FireConfig}; +use crate::config::{CommandEntry, FileScope, LoadedConfig, SourceKind}; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CompletionSuggestion { @@ -9,177 +9,738 @@ pub(crate) struct CompletionSuggestion { } pub(crate) fn completion_suggestions( - config: &FireConfig, + config: &LoadedConfig, words: &[String], ) -> Vec { if words.is_empty() { - return suggestions_with_prefix("", &config.commands); + return root_suggestions(config, ""); } - let mut available = &config.commands; + let prefix = words.last().map(String::as_str).unwrap_or(""); + let path = &words[..words.len() - 1]; - for token in &words[..words.len() - 1] { - let Some(entry) = available.get(token) else { - return Vec::new(); - }; - let Some(subcommands) = entry.subcommands() else { - return Vec::new(); - }; - available = subcommands; + if path.is_empty() { + let suggestions = root_suggestions(config, prefix); + if let Some(exact) = suggestions + .iter() + .find(|suggestion| suggestion.value == prefix) + { + return children_for_root_exact(config, &exact.value); + } + return suggestions; } - let prefix = words.last().map(String::as_str).unwrap_or(""); + let suggestions = children_for_path(config, path, prefix); + if let Some(exact) = suggestions + .iter() + .find(|suggestion| suggestion.value == prefix) + { + let mut exact_path = path.to_vec(); + exact_path.push(exact.value.clone()); + return children_for_exact_path(config, &exact_path); + } + suggestions +} + +pub(crate) fn render_with_descriptions(suggestions: &[CompletionSuggestion]) -> Vec { + suggestions + .iter() + .map(|suggestion| match suggestion.description.as_deref() { + Some(description) => { + format!( + "{}\t{}", + suggestion.value, + first_description_line(description) + ) + } + None => suggestion.value.clone(), + }) + .collect() +} + +pub(crate) fn render_values_only(suggestions: &[CompletionSuggestion]) -> Vec { + suggestions + .iter() + .map(|suggestion| suggestion.value.clone()) + .collect() +} + +fn root_suggestions(config: &LoadedConfig, prefix: &str) -> Vec { + let local_commands = local_commands(config, prefix); + let local_namespaces = local_namespaces(config, prefix); + let local_groups = local_groups(config, prefix); + let namespaces = global_namespaces(config, prefix); + let groups = global_groups(config, prefix); + let global_commands = global_direct_commands(config, prefix); + concat_suggestions(vec![ + local_commands, + local_namespaces, + local_groups, + namespaces, + groups, + global_commands, + ]) +} - if let Some(exact) = available.get(prefix) { - if let Some(subcommands) = exact.subcommands() { - return suggestions_with_prefix("", subcommands); +fn children_for_root_exact(config: &LoadedConfig, value: &str) -> Vec { + let command_children = root_command_children(config, value); + if !command_children.is_empty() { + return command_children; + } + + let namespace_children = namespace_children(config, value, ""); + if !namespace_children.is_empty() { + return namespace_children; + } + + group_children(config, value, "") +} + +fn children_for_path( + config: &LoadedConfig, + path: &[String], + prefix: &str, +) -> Vec { + if path.len() == 1 { + let head = &path[0]; + let command_children = root_command_children(config, head); + if !command_children.is_empty() { + return filter_prefix(prefix, command_children); } - return Vec::new(); + + let namespace_children = namespace_children(config, head, prefix); + if !namespace_children.is_empty() { + return namespace_children; + } + + return group_children(config, head, prefix); } - suggestions_with_prefix(prefix, available) + let candidates = children_for_exact_path(config, path); + filter_prefix(prefix, candidates) +} + +fn children_for_exact_path(config: &LoadedConfig, path: &[String]) -> Vec { + if path.is_empty() { + return root_suggestions(config, ""); + } + + if path.len() == 1 { + return children_for_root_exact(config, &path[0]); + } + + if let Some(suggestions) = nested_from_root_command(config, path) { + return suggestions; + } + if let Some(suggestions) = nested_from_namespace_scope(config, path) { + return suggestions; + } + if let Some(suggestions) = nested_from_group_scope(config, path) { + return suggestions; + } + if let Some(suggestions) = nested_from_namespace_prefix_scope(config, path) { + return suggestions; + } + + Vec::new() +} + +fn local_commands(config: &LoadedConfig, prefix: &str) -> Vec { + let mut map = BTreeMap::new(); + for file in &config.files { + if file.source != SourceKind::Local { + continue; + } + for (name, entry) in &file.commands { + if name.starts_with(prefix) { + map.insert(name.clone(), command_suggestion(name, entry)); + } + } + } + map.into_values().collect() } -pub(crate) fn suggestions_with_prefix( +fn global_namespaces(config: &LoadedConfig, prefix: &str) -> Vec { + let mut map = BTreeMap::new(); + for file in &config.files { + if file.source != SourceKind::Global { + continue; + } + match &file.scope { + FileScope::Namespace { + namespace, + namespace_description, + } + | FileScope::NamespaceGroup { + namespace, + namespace_description, + .. + } => { + if namespace.starts_with(prefix) { + map.insert( + namespace.clone(), + CompletionSuggestion { + value: namespace.clone(), + description: non_empty(namespace_description), + }, + ); + } + } + FileScope::Root | FileScope::Group { .. } => {} + } + } + map.into_values().collect() +} + +fn global_groups(config: &LoadedConfig, prefix: &str) -> Vec { + let mut groups = BTreeSet::new(); + for file in &config.files { + if file.source != SourceKind::Global { + continue; + } + if let FileScope::Group { group } = &file.scope { + if group.starts_with(prefix) { + groups.insert(group.clone()); + } + } + } + groups + .into_iter() + .map(|group| CompletionSuggestion { + value: group, + description: None, + }) + .collect() +} + +fn global_direct_commands(config: &LoadedConfig, prefix: &str) -> Vec { + let mut map = BTreeMap::new(); + for file in &config.files { + if file.source != SourceKind::Global { + continue; + } + if let FileScope::Root = file.scope { + for (name, entry) in &file.commands { + if name.starts_with(prefix) { + map.insert(name.clone(), command_suggestion(name, entry)); + } + } + } + } + map.into_values().collect() +} + +fn local_namespaces(config: &LoadedConfig, prefix: &str) -> Vec { + let mut map = BTreeMap::new(); + for file in &config.files { + if file.source != SourceKind::Local { + continue; + } + match &file.scope { + FileScope::Namespace { + namespace, + namespace_description, + } + | FileScope::NamespaceGroup { + namespace, + namespace_description, + .. + } => { + if namespace.starts_with(prefix) { + map.insert( + namespace.clone(), + CompletionSuggestion { + value: namespace.clone(), + description: non_empty(namespace_description), + }, + ); + } + } + FileScope::Root | FileScope::Group { .. } => {} + } + } + map.into_values().collect() +} + +fn local_groups(config: &LoadedConfig, prefix: &str) -> Vec { + let mut set = BTreeSet::new(); + for file in &config.files { + if file.source != SourceKind::Local { + continue; + } + if let FileScope::Group { group } = &file.scope { + if group.starts_with(prefix) { + set.insert(group.clone()); + } + } + } + set.into_iter() + .map(|group| CompletionSuggestion { + value: group, + description: None, + }) + .collect() +} + +fn namespace_children( + config: &LoadedConfig, + namespace: &str, prefix: &str, - commands: &BTreeMap, ) -> Vec { - commands - .iter() - .filter_map(|(name, entry)| { - if !name.starts_with(prefix) { - return None; + let commands = namespace_commands(config, namespace, prefix); + let groups = namespace_groups(config, namespace, prefix); + concat_suggestions(vec![commands, groups]) +} + +fn namespace_commands( + config: &LoadedConfig, + namespace: &str, + prefix: &str, +) -> Vec { + let mut map = BTreeMap::new(); + for file in &config.files { + if let FileScope::Namespace { + namespace: ns_alias, + .. + } = &file.scope + { + if ns_alias == namespace { + for (name, entry) in &file.commands { + if name.starts_with(prefix) { + map.insert(name.clone(), command_suggestion(name, entry)); + } + } } - let description = entry.description().unwrap_or_default().trim(); - if description.is_empty() { - Some(CompletionSuggestion { - value: name.clone(), - description: None, - }) - } else { - Some(CompletionSuggestion { - value: name.clone(), - description: Some(description.to_string()), - }) + } + } + map.into_values().collect() +} + +fn namespace_groups( + config: &LoadedConfig, + namespace: &str, + prefix: &str, +) -> Vec { + let mut groups = BTreeSet::new(); + for file in &config.files { + if let FileScope::NamespaceGroup { + namespace: ns_alias, + group, + .. + } = &file.scope + { + if ns_alias == namespace && group.starts_with(prefix) { + groups.insert(group.clone()); } + } + } + groups + .into_iter() + .map(|group| CompletionSuggestion { + value: group, + description: None, }) .collect() } -pub(crate) fn render_with_descriptions(suggestions: &[CompletionSuggestion]) -> Vec { - suggestions +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)); + } + } + } + } + } + map.into_values().collect() +} + +fn root_command_children(config: &LoadedConfig, command_name: &str) -> Vec { + for file in config.files.iter().rev() { + if let Some(command) = file.commands.get(command_name) { + return nested_subcommands(command, ""); + } + } + Vec::new() +} + +fn nested_from_root_command( + config: &LoadedConfig, + path: &[String], +) -> Option> { + let root_command = &path[0]; + let mut command = None; + for file in config.files.iter().rev() { + if let Some(candidate) = file.commands.get(root_command) { + command = Some(candidate); + break; + } + } + let command = command?; + Some(nested_command_path(command, &path[1..])) +} + +fn nested_from_namespace_scope( + config: &LoadedConfig, + path: &[String], +) -> Option> { + if path.len() < 2 { + return None; + } + let namespace = &path[0]; + let command_name = &path[1]; + let mut command = None; + for file in config.files.iter().rev() { + if let FileScope::Namespace { + namespace: ns_alias, + .. + } = &file.scope + { + if ns_alias == namespace { + if let Some(candidate) = file.commands.get(command_name) { + command = Some(candidate); + break; + } + } + } + } + let command = command?; + Some(nested_command_path(command, &path[2..])) +} + +fn nested_from_group_scope( + config: &LoadedConfig, + path: &[String], +) -> Option> { + if path.len() < 2 { + return None; + } + let group = &path[0]; + let command_name = &path[1]; + let mut command = None; + 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 command = command?; + Some(nested_command_path(command, &path[2..])) +} + +fn nested_from_namespace_prefix_scope( + config: &LoadedConfig, + path: &[String], +) -> Option> { + if path.len() < 2 { + return None; + } + let namespace = &path[0]; + let group = &path[1]; + + if path.len() == 2 { + let mut map = BTreeMap::new(); + for file in &config.files { + if let FileScope::NamespaceGroup { + namespace: ns_alias, + group: file_alias, + .. + } = &file.scope + { + if ns_alias == namespace && file_alias == group { + for (name, entry) in &file.commands { + map.insert(name.clone(), command_suggestion(name, entry)); + } + } + } + } + if map.is_empty() { + return None; + } + return Some(map.into_values().collect()); + } + + let command_name = &path[2]; + let mut command = None; + for file in config.files.iter().rev() { + if let FileScope::NamespaceGroup { + namespace: ns_alias, + group: file_alias, + .. + } = &file.scope + { + if ns_alias == namespace && file_alias == group { + if let Some(candidate) = file.commands.get(command_name) { + command = Some(candidate); + break; + } + } + } + } + let command = command?; + Some(nested_command_path(command, &path[3..])) +} + +fn nested_command_path(command: &CommandEntry, path: &[String]) -> Vec { + if path.is_empty() { + return nested_subcommands(command, ""); + } + + let mut current = command; + for segment in path { + let Some(subcommands) = current.subcommands() else { + return Vec::new(); + }; + let Some(next) = subcommands.get(segment) else { + return Vec::new(); + }; + current = next; + } + nested_subcommands(current, "") +} + +fn nested_subcommands(command: &CommandEntry, prefix: &str) -> Vec { + let Some(subcommands) = command.subcommands() else { + return Vec::new(); + }; + subcommands .iter() - .map(|suggestion| match suggestion.description.as_deref() { - Some(description) => format!( - "{}\t{}", - suggestion.value, - first_description_line(description) - ), - None => suggestion.value.clone(), + .filter_map(|(name, entry)| { + if !name.starts_with(prefix) { + return None; + } + Some(command_suggestion(name, entry)) }) .collect() } -pub(crate) fn render_values_only(suggestions: &[CompletionSuggestion]) -> Vec { +fn command_suggestion(name: &str, entry: &CommandEntry) -> CompletionSuggestion { + CompletionSuggestion { + value: name.to_string(), + description: non_empty(entry.description().unwrap_or_default()), + } +} + +fn filter_prefix( + prefix: &str, + suggestions: Vec, +) -> Vec { suggestions - .iter() - .map(|suggestion| suggestion.value.clone()) + .into_iter() + .filter(|suggestion| suggestion.value.starts_with(prefix)) .collect() } +fn concat_suggestions(groups: Vec>) -> Vec { + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + for group in groups { + for suggestion in group { + if seen.insert(suggestion.value.clone()) { + out.push(suggestion); + } + } + } + out +} + fn first_description_line(description: &str) -> &str { description.lines().next().unwrap_or("").trim() } +fn non_empty(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use super::*; + use crate::config::{FileConfig, FileScope, SourceKind}; - use crate::config::{CommandEntry, CommandSpec, FireConfig}; + fn config_with_scopes() -> LoadedConfig { + fn commands(yaml: &str) -> BTreeMap { + #[derive(serde::Deserialize)] + struct Wrapper { + commands: BTreeMap, + } + serde_yaml::from_str::(yaml) + .expect("valid yaml") + .commands + } - use super::*; + LoadedConfig { + files: vec![ + FileConfig { + source: SourceKind::Local, + scope: FileScope::Root, + commands: commands( + r#" +commands: + run: + description: run local + exec: npm run + dev: + description: local dev + exec: npm run dev +"#, + ), + }, + FileConfig { + source: SourceKind::Global, + scope: FileScope::Namespace { + namespace: "ex".to_string(), + namespace_description: "example namespace".to_string(), + }, + commands: commands( + r#" +commands: + api: + description: api command + exec: npm run api +"#, + ), + }, + FileConfig { + source: SourceKind::Global, + scope: FileScope::Group { + group: "backend".to_string(), + }, + commands: commands( + r#" +commands: + start: + description: start service + exec: npm run start +"#, + ), + }, + FileConfig { + source: SourceKind::Global, + scope: FileScope::Root, + commands: commands( + r#" +commands: + ping: + description: global direct command + exec: echo ping +"#, + ), + }, + FileConfig { + source: SourceKind::Global, + scope: FileScope::NamespaceGroup { + namespace: "ex".to_string(), + namespace_description: String::new(), + group: "ops".to_string(), + }, + commands: commands( + r#" +commands: + deploy: + description: deploy service + exec: npm run deploy +"#, + ), + }, + ], + } + } #[test] - fn completion_includes_description() { - let mut map = BTreeMap::new(); - map.insert( - "run".to_string(), - CommandEntry::Spec(CommandSpec { - description: "run scripts".to_string(), - ..CommandSpec::default() - }), - ); - map.insert( - "raw".to_string(), - CommandEntry::Shorthand("echo raw".to_string()), - ); + fn root_suggestions_respect_priority_groups() { + let config = config_with_scopes(); + let values = completion_suggestions(&config, &[]); + let names: Vec = values.into_iter().map(|it| it.value).collect(); + assert_eq!(names, vec!["dev", "run", "ex", "backend", "ping"]); + } - let values = suggestions_with_prefix("r", &map); - assert_eq!( - values, - vec![ - CompletionSuggestion { - value: "raw".to_string(), - description: None - }, - CompletionSuggestion { - value: "run".to_string(), - description: Some("run scripts".to_string()) - } - ] - ); + #[test] + fn namespace_lists_commands_and_nested_groups() { + let config = config_with_scopes(); + let values = completion_suggestions(&config, &["ex".to_string()]); + let names: Vec = values.into_iter().map(|it| it.value).collect(); + assert_eq!(names, vec!["api", "ops"]); } #[test] - fn completion_moves_into_subcommands_on_exact_match() { - let yaml = r#" -commands: - vars: - commands: - npm-version: echo ok - node-version: echo ok -"#; - let config: FireConfig = serde_yaml::from_str(yaml).expect("valid config"); - let words = vec!["vars".to_string()]; + fn namespace_prefix_lists_only_scoped_commands() { + let config = config_with_scopes(); + let values = completion_suggestions(&config, &["ex".to_string(), "ops".to_string()]); + let names: Vec = values.into_iter().map(|it| it.value).collect(); + assert_eq!(names, vec!["deploy"]); + } - let values = completion_suggestions(&config, &words); - assert_eq!( - values, - vec![ - CompletionSuggestion { - value: "node-version".to_string(), - description: None - }, - CompletionSuggestion { - value: "npm-version".to_string(), - description: None - } - ] + #[test] + fn namespace_prefix_filters_commands_by_prefix() { + let config = config_with_scopes(); + let values = completion_suggestions( + &config, + &["ex".to_string(), "ops".to_string(), "de".to_string()], ); + let names: Vec = values.into_iter().map(|it| it.value).collect(); + assert_eq!(names, vec!["deploy"]); } #[test] - fn completion_returns_empty_for_exact_command_without_subcommands() { - let yaml = r#" + fn local_command_from_namespace_prefix_exposes_nested_completion() { + fn commands(yaml: &str) -> BTreeMap { + #[derive(serde::Deserialize)] + struct Wrapper { + commands: BTreeMap, + } + serde_yaml::from_str::(yaml) + .expect("valid yaml") + .commands + } + + let config = LoadedConfig { + files: vec![FileConfig { + source: SourceKind::Local, + scope: FileScope::NamespaceGroup { + namespace: "ex".to_string(), + namespace_description: String::new(), + group: "ops".to_string(), + }, + commands: commands( + r#" commands: - run: npm run - run2: npm run test -"#; - let config: FireConfig = serde_yaml::from_str(yaml).expect("valid config"); - let words = vec!["run".to_string()]; + run: + exec: npm run + commands: + start: npm run start + test: npm run test +"#, + ), + }], + }; - let values = completion_suggestions(&config, &words); - assert!(values.is_empty()); + let values = completion_suggestions(&config, &["run".to_string()]); + let names: Vec = values.into_iter().map(|it| it.value).collect(); + assert_eq!(names, vec!["start", "test"]); } #[test] - fn render_with_descriptions_uses_only_first_line_of_description() { - let suggestions = vec![CompletionSuggestion { + fn render_with_descriptions_uses_only_first_line() { + let values = vec![CompletionSuggestion { value: "run".to_string(), - description: Some("run service\nwith custom host".to_string()), + description: Some("run service\nwith host".to_string()), }]; - - let rendered = render_with_descriptions(&suggestions); - assert_eq!(rendered, vec!["run\trun service".to_string()]); + assert_eq!( + render_with_descriptions(&values), + vec!["run\trun service".to_string()] + ); } } diff --git a/src/config.rs b/src/config.rs index 6736b9f..7c801ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,12 +6,43 @@ use std::{ use serde::Deserialize; -#[derive(Debug, Deserialize, Clone, Default)] -pub(crate) struct FireConfig { - #[serde(default)] +use crate::registry::load_installed_directories; + +#[derive(Debug, Clone, Default)] +pub(crate) struct LoadedConfig { + pub(crate) files: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SourceKind { + Local, + Global, +} + +#[derive(Debug, Clone)] +pub(crate) struct FileConfig { + pub(crate) source: SourceKind, + pub(crate) scope: FileScope, pub(crate) commands: BTreeMap, } +#[derive(Debug, Clone)] +pub(crate) enum FileScope { + Root, + Namespace { + namespace: String, + namespace_description: String, + }, + Group { + group: String, + }, + NamespaceGroup { + namespace: String, + namespace_description: String, + group: String, + }, +} + #[derive(Debug, Deserialize, Clone)] #[serde(untagged)] pub(crate) enum CommandEntry { @@ -38,6 +69,24 @@ pub(crate) enum CommandAction { Multiple(Vec), } +#[derive(Debug, Deserialize, Clone, Default)] +struct FireFileRaw { + #[serde(default)] + group: String, + #[serde(default)] + namespace: Option, + #[serde(default)] + commands: BTreeMap, +} + +#[derive(Debug, Deserialize, Clone, Default)] +struct NamespaceRaw { + #[serde(default)] + prefix: String, + #[serde(default)] + description: String, +} + impl CommandEntry { pub(crate) fn description(&self) -> Option<&str> { match self { @@ -74,35 +123,148 @@ impl CommandAction { } } -pub(crate) fn load_config() -> FireConfig { - let files = discover_config_files("."); - if files.is_empty() { - return FireConfig::default(); +pub(crate) fn load_config() -> LoadedConfig { + let mut loaded = LoadedConfig::default(); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + + // Local project commands. + let local_paths = discover_config_files(&cwd); + let local_raw = parse_raw_files(&local_paths); + let local_default_namespace = select_local_default_namespace_prefix(&local_raw, &cwd); + loaded.files.extend(to_file_configs( + &local_raw, + SourceKind::Local, + Some(&local_default_namespace), + )); + + // 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 { + continue; + } + let paths = discover_config_files(&directory); + let raw = parse_raw_files(&paths); + let default_namespace = select_directory_default_namespace_prefix(&raw); + loaded.files.extend(to_file_configs( + &raw, + SourceKind::Global, + default_namespace.as_deref(), + )); } - let mut merged = FireConfig::default(); - for file in files { - let Ok(text) = fs::read_to_string(&file) else { - eprintln!("[fire] Could not read {}. Skipping.", file.display()); + loaded +} + +fn parse_raw_files(paths: &[PathBuf]) -> Vec { + let mut parsed = Vec::new(); + for path in paths { + let Ok(text) = fs::read_to_string(path) else { + eprintln!("[fire] Could not read {}. Skipping.", path.display()); continue; }; - - let parsed: FireConfig = match serde_yaml::from_str(&text) { + let value: FireFileRaw = match serde_yaml::from_str(&text) { Ok(value) => value, Err(err) => { eprintln!( "[fire] Invalid YAML in {}: {}. Skipping.", - file.display(), + path.display(), err ); continue; } }; + parsed.push(value); + } + parsed +} + +fn to_file_configs( + raw_files: &[FireFileRaw], + source: SourceKind, + default_namespace_prefix: Option<&str>, +) -> Vec { + raw_files + .iter() + .map(|raw| FileConfig { + source, + scope: scope_from_raw(raw, default_namespace_prefix), + commands: raw.commands.clone(), + }) + .collect() +} - merged.commands.extend(parsed.commands); +fn scope_from_raw(raw: &FireFileRaw, default_namespace_prefix: Option<&str>) -> FileScope { + let group = raw.group.trim(); + let namespace_prefix = raw + .namespace + .as_ref() + .map(|namespace| namespace.prefix.trim()) + .filter(|value| !value.is_empty()) + .or(default_namespace_prefix) + .unwrap_or(""); + let namespace_description = raw + .namespace + .as_ref() + .map(|namespace| namespace.description.trim()) + .unwrap_or("") + .to_string(); + + match (namespace_prefix.is_empty(), group.is_empty()) { + (true, true) => FileScope::Root, + (false, true) => FileScope::Namespace { + namespace: namespace_prefix.to_string(), + namespace_description, + }, + (true, false) => FileScope::Group { + group: group.to_string(), + }, + (false, false) => FileScope::NamespaceGroup { + namespace: namespace_prefix.to_string(), + namespace_description, + group: group.to_string(), + }, } +} - merged +fn select_directory_default_namespace_prefix(raw_files: &[FireFileRaw]) -> Option { + for raw in raw_files { + if let Some(namespace) = &raw.namespace { + let prefix = namespace.prefix.trim(); + if !prefix.is_empty() { + return Some(prefix.to_string()); + } + } + } + None +} + +fn select_local_default_namespace_prefix(raw_files: &[FireFileRaw], cwd: &Path) -> String { + if let Some(prefix) = select_directory_default_namespace_prefix(raw_files) { + return prefix; + } + + let raw = cwd + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("default"); + let normalized = raw + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + let trimmed = normalized.trim_matches('-'); + if trimmed.is_empty() { + "default".to_string() + } else { + trimmed.to_string() + } } fn discover_config_files(base_dir: impl AsRef) -> Vec { @@ -138,3 +300,57 @@ fn discover_config_files(base_dir: impl AsRef) -> Vec { base_files.extend(pattern_files); base_files } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_namespace_uses_directory_explicit_prefix() { + let raw = FireFileRaw::default(); + let scope = scope_from_raw(&raw, Some("ex")); + match scope { + FileScope::Namespace { namespace, .. } => assert_eq!(namespace, "ex"), + _ => panic!("expected namespace scope"), + } + } + + #[test] + fn missing_namespace_without_directory_prefix_stays_root() { + let raw = FireFileRaw::default(); + let scope = scope_from_raw(&raw, None); + match scope { + FileScope::Root => {} + _ => panic!("expected root scope"), + } + } + + #[test] + fn select_directory_default_namespace_prefix_reads_first_explicit() { + let files = vec![ + FireFileRaw { + group: String::new(), + namespace: Some(NamespaceRaw { + prefix: "ex".to_string(), + description: String::new(), + }), + commands: BTreeMap::new(), + }, + FireFileRaw { + group: String::new(), + namespace: None, + commands: BTreeMap::new(), + }, + ]; + let selected = select_directory_default_namespace_prefix(&files); + assert_eq!(selected, Some("ex".to_string())); + } + + #[test] + fn select_local_default_namespace_prefix_falls_back_to_directory_name() { + let files = vec![FireFileRaw::default()]; + let cwd = PathBuf::from("/tmp/My Project"); + let selected = select_local_default_namespace_prefix(&files, &cwd); + assert_eq!(selected, "my-project".to_string()); + } +} diff --git a/src/execute.rs b/src/execute.rs index 05dbea5..48fec57 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -6,13 +6,20 @@ pub(crate) fn execute_resolved_command(resolved: ResolvedCommand<'_>) -> ! { let Some(commands_to_run) = resolved.command.execution_commands() else { eprintln!("[fire] Command path has no executable action."); if let Some(subcommands) = resolved.command.subcommands() { - eprintln!("[fire] Available subcommands:"); + eprintln!("Commands:"); + let width = subcommands + .keys() + .map(|name| name.len()) + .max() + .unwrap_or(0) + .max(1); for (name, entry) in subcommands { let description = entry.description().unwrap_or_default(); if description.is_empty() { - eprintln!(" - {name}"); + eprintln!(" {name}"); } else { - eprintln!(" - {name}\t{description}"); + let short = description.lines().next().unwrap_or("").trim(); + eprintln!(" {:width$} {}", name, short, width = width); } } } diff --git a/src/help.rs b/src/help.rs index 81ddb0c..8267819 100644 --- a/src/help.rs +++ b/src/help.rs @@ -1,8 +1,48 @@ -use crate::config::CommandEntry; +use std::collections::BTreeMap; + +use crate::config::{CommandEntry, FileScope, LoadedConfig}; + +pub(crate) fn print_root_help(config: &LoadedConfig) { + let local_commands = local_commands(config); + let namespaces = namespaces(config); + let groups = groups(config); + let global_commands = global_direct_commands(config); + + println!("Fire CLI"); + print_section("Commands", &local_commands); + print_section("Namespaces", &namespaces); + print_section("Groups", &groups); + print_section("Global Commands", &global_commands); +} + +pub(crate) fn print_scope_help(config: &LoadedConfig, path: &[String]) -> bool { + match path { + [namespace] => { + if !has_namespace(config, namespace) { + if has_root_group(config, namespace) { + print_group_help(config, namespace); + return true; + } + return false; + } + print_namespace_help(config, namespace); + true + } + [namespace, group] => { + if has_namespace_prefix(config, namespace, group) { + print_namespace_prefix_help(config, namespace, group); + true + } else { + false + } + } + _ => false, + } +} pub(crate) fn print_command_help(command_path: &[String], command: &CommandEntry) { if command_path.is_empty() { - println!("Fire CLI help"); + println!("Fire CLI"); } else { println!("Command: {}", command_path.join(" ")); } @@ -15,50 +55,342 @@ pub(crate) fn print_command_help(command_path: &[String], command: &CommandEntry } } - if let Some(subcommands) = command.subcommands() { - if !subcommands.is_empty() { - println!("Subcommands:"); - for (name, entry) in subcommands { - let description = first_description_line(entry.description().unwrap_or_default()); - if description.is_empty() { - println!(" - {name}"); - } else { - println!(" - {name}\t{description}"); + let subcommands = command_subcommands(command); + print_section("Commands", &subcommands); +} + +fn print_namespace_help(config: &LoadedConfig, namespace: &str) { + let title = format!("Namespace: {namespace}"); + println!("{title}"); + let commands = namespace_commands(config, namespace); + let groups = namespace_groups(config, namespace); + print_section("Commands", &commands); + print_section("Groups", &groups); +} + +fn print_group_help(config: &LoadedConfig, group: &str) { + let title = format!("Group: {group}"); + println!("{title}"); + let commands = group_commands(config, group); + print_section("Commands", &commands); +} + +fn print_namespace_prefix_help(config: &LoadedConfig, namespace: &str, group: &str) { + let title = format!("Namespace Group: {namespace} {group}"); + println!("{title}"); + let commands = namespace_prefix_commands(config, namespace, group); + print_section("Commands", &commands); +} + +fn print_section(title: &str, items: &[(String, Option)]) { + if items.is_empty() { + return; + } + + println!("{title}:"); + let width = items + .iter() + .map(|(name, _)| name.len()) + .max() + .unwrap_or(0) + .max(1); + + for (name, description) in items { + if let Some(description) = description.as_deref() { + let first_line = description.lines().next().unwrap_or("").trim(); + if first_line.is_empty() { + println!(" {name}"); + } else { + println!(" {:width$} {}", name, first_line, width = width); + } + } else { + println!(" {name}"); + } + } +} + +fn local_commands(config: &LoadedConfig) -> Vec<(String, Option)> { + let mut map = BTreeMap::new(); + for file in &config.files { + if file.source != crate::config::SourceKind::Local { + continue; + } + for (name, entry) in &file.commands { + map.insert(name.clone(), optional_description(entry)); + } + } + map.into_iter().collect() +} + +fn namespaces(config: &LoadedConfig) -> Vec<(String, Option)> { + let mut map = BTreeMap::new(); + for file in &config.files { + match &file.scope { + FileScope::Namespace { + namespace, + namespace_description, + } + | FileScope::NamespaceGroup { + namespace, + namespace_description, + .. + } => { + map.insert(namespace.clone(), non_empty(namespace_description)); + } + FileScope::Root | FileScope::Group { .. } => {} + } + } + map.into_iter().collect() +} + +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); + } + } + map.into_iter().collect() +} + +fn global_direct_commands(config: &LoadedConfig) -> Vec<(String, Option)> { + let mut map = BTreeMap::new(); + for file in &config.files { + if file.source != crate::config::SourceKind::Global { + continue; + } + if let FileScope::Root = file.scope { + for (name, entry) in &file.commands { + map.insert(name.clone(), optional_description(entry)); + } + } + } + map.into_iter().collect() +} + +fn command_subcommands(command: &CommandEntry) -> Vec<(String, Option)> { + let Some(subcommands) = command.subcommands() else { + return Vec::new(); + }; + subcommands + .iter() + .map(|(name, entry)| (name.clone(), optional_description(entry))) + .collect() +} + +fn namespace_commands(config: &LoadedConfig, namespace: &str) -> Vec<(String, Option)> { + let mut map = BTreeMap::new(); + for file in &config.files { + match &file.scope { + FileScope::Namespace { + namespace: ns_alias, + .. + } => { + if ns_alias == namespace { + for (name, entry) in &file.commands { + map.insert(name.clone(), optional_description(entry)); + } + } + } + FileScope::NamespaceGroup { .. } => {} + FileScope::Root | FileScope::Group { .. } => {} + } + } + map.into_iter().collect() +} + +fn namespace_groups(config: &LoadedConfig, namespace: &str) -> Vec<(String, Option)> { + let mut map = BTreeMap::new(); + for file in &config.files { + if let FileScope::NamespaceGroup { + namespace: ns_alias, + group, + .. + } = &file.scope + { + if ns_alias == namespace { + map.insert(group.clone(), None); + } + } + } + map.into_iter().collect() +} + +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)); + } + } + } + } + map.into_iter().collect() +} + +fn namespace_prefix_commands( + config: &LoadedConfig, + namespace: &str, + group: &str, +) -> Vec<(String, Option)> { + let mut map = BTreeMap::new(); + for file in &config.files { + if let FileScope::NamespaceGroup { + namespace: ns_alias, + group: file_alias, + .. + } = &file.scope + { + if ns_alias == namespace && file_alias == group { + for (name, entry) in &file.commands { + map.insert(name.clone(), optional_description(entry)); } } } } + map.into_iter().collect() } -fn first_description_line(description: &str) -> &str { - description.lines().next().unwrap_or("").trim() +fn has_namespace(config: &LoadedConfig, namespace: &str) -> bool { + config.files.iter().any(|file| { + matches!( + &file.scope, + FileScope::Namespace { + namespace: ns_alias, + .. + } if ns_alias == namespace + ) || matches!( + &file.scope, + FileScope::NamespaceGroup { + namespace: ns_alias, + .. + } if ns_alias == namespace + ) + }) +} + +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), + ) +} + +fn has_namespace_prefix(config: &LoadedConfig, namespace: &str, group: &str) -> bool { + config.files.iter().any(|file| { + matches!( + &file.scope, + FileScope::NamespaceGroup { + namespace: ns_alias, + group: file_alias, + .. + } if ns_alias == namespace && file_alias == group + ) + }) +} + +fn optional_description(entry: &CommandEntry) -> Option { + non_empty(entry.description().unwrap_or_default()) +} + +fn non_empty(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } } #[cfg(test)] mod tests { - use crate::config::FireConfig; + use std::collections::BTreeMap; + + use crate::config::{CommandEntry, FileConfig, FileScope, LoadedConfig, SourceKind}; use super::*; - #[test] - fn first_description_line_uses_first_line_only() { - assert_eq!(first_description_line("line one\nline two"), "line one"); + fn parse_commands(yaml: &str) -> BTreeMap { + #[derive(serde::Deserialize)] + struct Wrapper { + commands: BTreeMap, + } + serde_yaml::from_str::(yaml) + .expect("valid yaml") + .commands } - #[test] - fn print_help_is_stable_with_command_spec() { - let yaml = r#" + fn sample_config() -> LoadedConfig { + LoadedConfig { + files: vec![ + FileConfig { + source: SourceKind::Local, + scope: FileScope::Root, + commands: parse_commands( + r#" commands: run: - description: Run scripts + description: Run local exec: npm run - commands: - start: - description: Start app - exec: npm run start -"#; - let config: FireConfig = serde_yaml::from_str(yaml).expect("valid config"); - let command = config.commands.get("run").expect("run command"); - print_command_help(&["run".to_string()], command); +"#, + ), + }, + FileConfig { + source: SourceKind::Global, + scope: FileScope::Namespace { + namespace: "ex".to_string(), + namespace_description: "Example".to_string(), + }, + commands: parse_commands( + r#" +commands: + api: + description: API command + exec: npm run api +"#, + ), + }, + FileConfig { + source: SourceKind::Global, + scope: FileScope::NamespaceGroup { + namespace: "ex".to_string(), + namespace_description: "Example".to_string(), + group: "backend".to_string(), + }, + commands: parse_commands( + r#" +commands: + deploy: + description: Deploy command + exec: npm run deploy +"#, + ), + }, + ], + } + } + + #[test] + fn scope_help_detects_namespace() { + let config = sample_config(); + assert!(print_scope_help(&config, &["ex".to_string()])); + } + + #[test] + fn scope_help_detects_namespace_prefix() { + let config = sample_config(); + assert!(print_scope_help( + &config, + &["ex".to_string(), "backend".to_string()] + )); + } + + #[test] + fn namespace_commands_exclude_namespace_group_commands() { + let config = sample_config(); + let commands = namespace_commands(&config, "ex"); + let names: Vec = commands.into_iter().map(|(name, _)| name).collect(); + assert_eq!(names, vec!["api".to_string()]); } } diff --git a/src/lib.rs b/src/lib.rs index 44cc768..aca4c24 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,12 +4,14 @@ mod completion; mod config; mod execute; mod help; +mod registry; mod resolve; use completion::{completion_suggestions, render_values_only, render_with_descriptions}; -use config::{load_config, FireConfig}; +use config::load_config; use execute::execute_resolved_command; -use help::print_command_help; +use help::{print_command_help, print_root_help, print_scope_help}; +use registry::{install_directory, InstallResult}; use resolve::resolve_command; pub fn setup_cli() { @@ -42,50 +44,82 @@ pub fn setup_cli() { let command_args = &args[1..]; - if command_args.is_empty() { - print_available_commands(&config); + if command_args.first().map(String::as_str) == Some("cli") { + handle_cli_command(command_args); return; } - if command_args[0] == "cli" { - eprintln!("[fire] `cli` command is reserved for Fire CLI management."); + if command_args.is_empty() { + print_root_help(&config); return; } if let Some(help_target) = extract_help_target(command_args) { if help_target.is_empty() { - print_available_commands(&config); + print_root_help(&config); return; } - let Some(resolved) = resolve_command(&config.commands, help_target) else { - eprintln!("[fire] Unknown command: {}", help_target[0]); - print_available_commands(&config); - process::exit(1); - }; + if let Some(resolved) = resolve_command(&config, help_target) { + let command_path = &help_target[..resolved.consumed]; + print_command_help(command_path, resolved.command); + return; + } - let command_path = &help_target[..resolved.consumed]; - print_command_help(command_path, resolved.command); - return; - } + if print_scope_help(&config, help_target) { + return; + } - let Some(resolved) = resolve_command(&config.commands, command_args) else { - eprintln!("[fire] Unknown command: {}", command_args[0]); - print_available_commands(&config); + eprintln!("[fire] Unknown command: {}", help_target[0]); + print_root_help(&config); process::exit(1); - }; + } - execute_resolved_command(resolved); + if let Some(resolved) = resolve_command(&config, command_args) { + let command_path = &command_args[..resolved.consumed]; + if resolved.command.execution_commands().is_none() { + print_command_help(command_path, resolved.command); + return; + } + execute_resolved_command(resolved); + } + + if print_scope_help(&config, command_args) { + return; + } + + eprintln!("[fire] Unknown command: {}", command_args[0]); + print_root_help(&config); + process::exit(1); } -fn print_available_commands(config: &FireConfig) { - println!("Fire CLI - available commands:"); - for (name, entry) in &config.commands { - let description = entry.description().unwrap_or_default().trim(); - if description.is_empty() { - println!(" - {name}"); - } else { - println!(" - {name}\t{description}"); +fn handle_cli_command(command_args: &[String]) { + match command_args { + [cli, install] if cli == "cli" && install == "install" => { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + match install_directory(&cwd) { + Ok(InstallResult::Added) => { + println!("Installed directory: {}", cwd.display()); + } + Ok(InstallResult::AlreadyInstalled) => { + println!("Directory already installed: {}", cwd.display()); + } + Err(err) => { + eprintln!("[fire] Failed to install directory: {err}"); + process::exit(1); + } + } + } + [cli] if cli == "cli" => { + println!("Fire CLI Management"); + println!("Commands:"); + println!(" install Register the current directory for global command loading"); + } + _ => { + eprintln!("[fire] Unknown cli command"); + eprintln!("Usage:"); + eprintln!(" fire cli install"); + process::exit(1); } } } diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..c4108f2 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,80 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +struct RegistryFile { + #[serde(default)] + installed_dirs: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InstallResult { + Added, + AlreadyInstalled, +} + +pub(crate) fn load_installed_directories() -> Vec { + let path = registry_path(); + let Ok(text) = fs::read_to_string(path) else { + return Vec::new(); + }; + let parsed: RegistryFile = match serde_yaml::from_str(&text) { + Ok(value) => value, + Err(_) => return Vec::new(), + }; + + parsed + .installed_dirs + .into_iter() + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .collect() +} + +pub(crate) fn install_directory(directory: &Path) -> Result { + let absolute = directory + .canonicalize() + .map_err(|err| format!("Cannot resolve directory path: {err}"))?; + + let mut installed = load_installed_directories(); + if installed.iter().any(|path| path == &absolute) { + return Ok(InstallResult::AlreadyInstalled); + } + + installed.push(absolute); + installed.sort(); + installed.dedup(); + + write_registry(&installed) +} + +fn write_registry(installed: &[PathBuf]) -> Result { + let parent = registry_path() + .parent() + .ok_or_else(|| "Invalid registry path".to_string())? + .to_path_buf(); + fs::create_dir_all(&parent).map_err(|err| format!("Cannot create config directory: {err}"))?; + + let data = RegistryFile { + installed_dirs: installed + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(), + }; + let content = + serde_yaml::to_string(&data).map_err(|err| format!("Cannot serialize registry: {err}"))?; + fs::write(registry_path(), content) + .map_err(|err| format!("Cannot write registry file: {err}"))?; + Ok(InstallResult::Added) +} + +fn registry_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("fire") + .join("installed-dirs.yml") +} diff --git a/src/resolve.rs b/src/resolve.rs index ca76156..7c36384 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -1,6 +1,4 @@ -use std::collections::BTreeMap; - -use crate::config::CommandEntry; +use crate::config::{CommandEntry, FileScope, LoadedConfig}; pub(crate) struct ResolvedCommand<'a> { pub(crate) command: &'a CommandEntry, @@ -9,86 +7,210 @@ pub(crate) struct ResolvedCommand<'a> { } pub(crate) fn resolve_command<'a>( - commands: &'a BTreeMap, + config: &'a LoadedConfig, args: &'a [String], ) -> Option> { - let mut consumed = 1; - let mut current = commands.get(args.first()?)?; + let mut best: Option> = None; - while consumed < args.len() { - let Some(subcommands) = current.subcommands() else { - break; - }; - if let Some(next) = subcommands.get(&args[consumed]) { - current = next; - consumed += 1; - continue; + 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 { + continue; + }; + + let mut consumed = base_consumed; + let mut current = command_entry; + + while consumed < args.len() { + let Some(subcommands) = current.subcommands() else { + break; + }; + if let Some(next) = subcommands.get(&args[consumed]) { + current = next; + consumed += 1; + continue; + } + break; + } + + let candidate = ResolvedCommand { + command: current, + consumed, + remaining_args: &args[consumed..], + }; + + if better_than(file.source, &candidate, best.as_ref()) { + best = Some(candidate); + } } - break; } - Some(ResolvedCommand { - command: current, - consumed, - remaining_args: &args[consumed..], - }) + 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 => { + if args.first().map(String::as_str) == Some(command_name) { + Some(1) + } else { + None + } + } + FileScope::Namespace { namespace, .. } => { + if args.first().map(String::as_str) == Some(namespace.as_str()) + && args.get(1).map(String::as_str) == Some(command_name) + { + Some(2) + } else { + None + } + } + FileScope::Group { group } => { + if args.first().map(String::as_str) == Some(group.as_str()) + && args.get(1).map(String::as_str) == Some(command_name) + { + Some(2) + } else { + None + } + } + FileScope::NamespaceGroup { + namespace, group, .. + } => { + if args.first().map(String::as_str) == Some(namespace.as_str()) + && args.get(1).map(String::as_str) == Some(group.as_str()) + && args.get(2).map(String::as_str) == Some(command_name) + { + Some(3) + } else { + None + } + } + }; + + scoped_match.or(local_match) +} + +fn better_than( + source: crate::config::SourceKind, + candidate: &ResolvedCommand<'_>, + current: Option<&ResolvedCommand<'_>>, +) -> bool { + match current { + None => true, + Some(existing) => { + if candidate.consumed != existing.consumed { + candidate.consumed > existing.consumed + } else { + source == crate::config::SourceKind::Local + } + } + } } #[cfg(test)] mod tests { - use crate::config::FireConfig; + use std::collections::BTreeMap; + + use crate::config::{CommandEntry, FileConfig, FileScope, LoadedConfig, SourceKind}; use super::*; - fn sample_config() -> FireConfig { + fn parse_commands(yaml: &str) -> BTreeMap { + #[derive(serde::Deserialize)] + struct Wrapper { + commands: BTreeMap, + } + + serde_yaml::from_str::(yaml) + .expect("valid yaml") + .commands + } + + #[test] + fn resolves_root_command_without_scope() { let yaml = r#" commands: run: description: run npm script exec: npm run - commands: - build: npm run build - test: - description: run tests - run: npm run test "#; - serde_yaml::from_str(yaml).expect("valid config") + let config = LoadedConfig { + files: vec![FileConfig { + source: SourceKind::Local, + scope: FileScope::Root, + commands: parse_commands(yaml), + }], + }; + let args = vec!["run".to_string(), "start".to_string()]; + let resolved = resolve_command(&config, &args).expect("resolved"); + + let commands = resolved.command.execution_commands().expect("exec"); + assert_eq!(commands, vec!["npm run".to_string()]); + assert_eq!(resolved.remaining_args, &["start".to_string()]); } #[test] - fn resolves_deepest_subcommand_and_passes_remaining_args() { - let config = sample_config(); + fn resolves_namespace_prefix_command() { + let yaml = r#" +commands: + api: + exec: npm run api +"#; + let config = LoadedConfig { + files: vec![FileConfig { + source: SourceKind::Global, + scope: FileScope::NamespaceGroup { + namespace: "ex".to_string(), + namespace_description: String::new(), + group: "backend".to_string(), + }, + commands: parse_commands(yaml), + }], + }; + let args = vec![ - "run".to_string(), - "build".to_string(), - "--host".to_string(), - "0.0.0.0".to_string(), + "ex".to_string(), + "backend".to_string(), + "api".to_string(), + "--watch".to_string(), ]; - let resolved = resolve_command(&config.commands, &args).expect("resolved"); + let resolved = resolve_command(&config, &args).expect("resolved"); - let commands = resolved.command.execution_commands().expect("exec"); - assert_eq!(commands, vec!["npm run build".to_string()]); - assert_eq!( - resolved.remaining_args, - &["--host".to_string(), "0.0.0.0".to_string()] - ); + assert_eq!(resolved.consumed, 3); + assert_eq!(resolved.remaining_args, &["--watch".to_string()]); } #[test] - fn falls_back_to_parent_exec_when_subcommand_does_not_match() { - let config = sample_config(); - let args = vec![ - "run".to_string(), - "start".to_string(), - "--watch".to_string(), - ]; - let resolved = resolve_command(&config.commands, &args).expect("resolved"); + fn resolves_namespace_prefix_command_from_local_root_without_prefix() { + let yaml = r#" +commands: + api: + exec: npm run api +"#; + let config = LoadedConfig { + files: vec![FileConfig { + source: SourceKind::Global, + scope: FileScope::NamespaceGroup { + namespace: "ex".to_string(), + namespace_description: String::new(), + group: "backend".to_string(), + }, + commands: parse_commands(yaml), + }], + }; - let commands = resolved.command.execution_commands().expect("exec"); - assert_eq!(commands, vec!["npm run".to_string()]); - assert_eq!( - resolved.remaining_args, - &["start".to_string(), "--watch".to_string()] - ); + let args = vec!["api".to_string(), "--watch".to_string()]; + let resolved = resolve_command(&config, &args).expect("resolved"); + + assert_eq!(resolved.consumed, 1); + assert_eq!(resolved.remaining_args, &["--watch".to_string()]); } } From 281e0fd1dfb4137dd07f7364616462ba3ed96393 Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Sun, 1 Mar 2026 00:28:43 -0600 Subject: [PATCH 02/10] include directive --- README.md | 6 + config.sample.fire.yml | 3 +- samples/test.fire.yml | 4 + schemas/fire.schema.json | 2 +- src/config.rs | 360 +++++++++++++++++++++++++++++++++++---- 5 files changed, 336 insertions(+), 39 deletions(-) create mode 100644 samples/test.fire.yml diff --git a/README.md b/README.md index 9bb3e28..de5cc2f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,12 @@ Files are merged in this order: If the same command name appears more than once, the last loaded definition wins. +Optional local include directories: +- Define `include` in a root fire file to extend local loading into specific subdirectories. +- Paths must be relative to the current directory (`samples/`, `tools/`), and Fire only scans each included directory root (non-recursive). +- Included files follow the same scope rules (`namespace`, `group`, commands). If a root file defines `namespace.prefix`, included files inherit that namespace. +- In globally installed directories, `include` is also honored using the installed directory as base path. + Example: ```yaml diff --git a/config.sample.fire.yml b/config.sample.fire.yml index c127988..97db877 100644 --- a/config.sample.fire.yml +++ b/config.sample.fire.yml @@ -26,8 +26,7 @@ runtimes: - scripts/helpers/*.py include: - - common.fire.yml - - database.fire.yml + - samples/ x-arg-config: &arg-config # Use {n} to represent the argument index: {{n}} -> {1}, {2}, ... ; #{n} -> #1, #2, ... diff --git a/samples/test.fire.yml b/samples/test.fire.yml new file mode 100644 index 0000000..14e12ab --- /dev/null +++ b/samples/test.fire.yml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=../schemas/fire.schema.json + +commands: + test: echo from test command diff --git a/schemas/fire.schema.json b/schemas/fire.schema.json index 00d77c4..7cc58df 100644 --- a/schemas/fire.schema.json +++ b/schemas/fire.schema.json @@ -29,7 +29,7 @@ }, "include": { "type": "array", - "description": "List of additional Fire config files to merge/include.", + "description": "List of additional local directories (relative to current directory) where Fire should load fire config files. Search is direct-level only (non-recursive).", "items": { "type": "string" } diff --git a/src/config.rs b/src/config.rs index 7c801ea..cff1be5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,7 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, fs, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, }; use serde::Deserialize; @@ -76,6 +76,8 @@ struct FireFileRaw { #[serde(default)] namespace: Option, #[serde(default)] + include: Vec, + #[serde(default)] commands: BTreeMap, } @@ -87,6 +89,12 @@ struct NamespaceRaw { description: String, } +#[derive(Debug, Clone)] +struct NamespaceScope { + prefix: String, + description: String, +} + impl CommandEntry { pub(crate) fn description(&self) -> Option<&str> { match self { @@ -128,14 +136,9 @@ pub(crate) fn load_config() -> LoadedConfig { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); // Local project commands. - let local_paths = discover_config_files(&cwd); - let local_raw = parse_raw_files(&local_paths); - let local_default_namespace = select_local_default_namespace_prefix(&local_raw, &cwd); - loaded.files.extend(to_file_configs( - &local_raw, - SourceKind::Local, - Some(&local_default_namespace), - )); + loaded + .files + .extend(load_local_file_configs(&cwd, SourceKind::Local)); // Global installed directories. No implicit namespace here: // files without namespace/group stay as direct global commands. @@ -144,19 +147,58 @@ pub(crate) fn load_config() -> LoadedConfig { if directory == cwd { continue; } - let paths = discover_config_files(&directory); - let raw = parse_raw_files(&paths); - let default_namespace = select_directory_default_namespace_prefix(&raw); - loaded.files.extend(to_file_configs( - &raw, - SourceKind::Global, - default_namespace.as_deref(), - )); + loaded + .files + .extend(load_global_file_configs(&directory, SourceKind::Global)); } loaded } +fn load_local_file_configs(cwd: &Path, source: SourceKind) -> Vec { + load_directory_file_configs(cwd, source, DefaultNamespaceMode::DirectoryFallback) +} + +fn load_global_file_configs(directory: &Path, source: SourceKind) -> Vec { + load_directory_file_configs(directory, source, DefaultNamespaceMode::ExplicitOnly) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DefaultNamespaceMode { + DirectoryFallback, + ExplicitOnly, +} + +fn load_directory_file_configs( + base_dir: &Path, + source: SourceKind, + namespace_mode: DefaultNamespaceMode, +) -> Vec { + let root_paths = discover_config_files(base_dir); + let root_raw = parse_raw_files(&root_paths); + let explicit_namespace = select_directory_explicit_namespace(&root_raw); + + let default_namespace = match namespace_mode { + DefaultNamespaceMode::DirectoryFallback => { + Some(select_local_default_namespace_prefix(&root_raw, base_dir)) + } + DefaultNamespaceMode::ExplicitOnly => select_directory_default_namespace_prefix(&root_raw), + }; + + let include_dirs = resolve_include_directories(base_dir, &root_raw); + let include_paths = discover_config_files_from_dirs(&include_dirs); + let include_raw = parse_raw_files(&include_paths); + + let mut files = to_file_configs(&root_raw, source, default_namespace.as_deref(), None); + files.extend(to_file_configs( + &include_raw, + source, + default_namespace.as_deref(), + explicit_namespace.as_ref(), + )); + files +} + fn parse_raw_files(paths: &[PathBuf]) -> Vec { let mut parsed = Vec::new(); for path in paths { @@ -184,32 +226,42 @@ fn to_file_configs( raw_files: &[FireFileRaw], source: SourceKind, default_namespace_prefix: Option<&str>, + forced_namespace: Option<&NamespaceScope>, ) -> Vec { raw_files .iter() .map(|raw| FileConfig { source, - scope: scope_from_raw(raw, default_namespace_prefix), + scope: scope_from_raw(raw, default_namespace_prefix, forced_namespace), commands: raw.commands.clone(), }) .collect() } -fn scope_from_raw(raw: &FireFileRaw, default_namespace_prefix: Option<&str>) -> FileScope { +fn scope_from_raw( + raw: &FireFileRaw, + default_namespace_prefix: Option<&str>, + forced_namespace: Option<&NamespaceScope>, +) -> FileScope { let group = raw.group.trim(); - let namespace_prefix = raw - .namespace - .as_ref() - .map(|namespace| namespace.prefix.trim()) - .filter(|value| !value.is_empty()) - .or(default_namespace_prefix) - .unwrap_or(""); - let namespace_description = raw - .namespace - .as_ref() - .map(|namespace| namespace.description.trim()) - .unwrap_or("") - .to_string(); + let (namespace_prefix, namespace_description) = if let Some(namespace) = forced_namespace { + (namespace.prefix.as_str(), namespace.description.clone()) + } else { + let prefix = raw + .namespace + .as_ref() + .map(|namespace| namespace.prefix.trim()) + .filter(|value| !value.is_empty()) + .or(default_namespace_prefix) + .unwrap_or(""); + let description = raw + .namespace + .as_ref() + .map(|namespace| namespace.description.trim()) + .unwrap_or("") + .to_string(); + (prefix, description) + }; match (namespace_prefix.is_empty(), group.is_empty()) { (true, true) => FileScope::Root, @@ -229,11 +281,18 @@ fn scope_from_raw(raw: &FireFileRaw, default_namespace_prefix: Option<&str>) -> } fn select_directory_default_namespace_prefix(raw_files: &[FireFileRaw]) -> Option { + select_directory_explicit_namespace(raw_files).map(|namespace| namespace.prefix) +} + +fn select_directory_explicit_namespace(raw_files: &[FireFileRaw]) -> Option { for raw in raw_files { if let Some(namespace) = &raw.namespace { let prefix = namespace.prefix.trim(); if !prefix.is_empty() { - return Some(prefix.to_string()); + return Some(NamespaceScope { + prefix: prefix.to_string(), + description: namespace.description.trim().to_string(), + }); } } } @@ -301,14 +360,89 @@ fn discover_config_files(base_dir: impl AsRef) -> Vec { base_files } +fn discover_config_files_from_dirs(directories: &[PathBuf]) -> Vec { + let mut files = BTreeSet::new(); + for directory in directories { + for path in discover_config_files(directory) { + files.insert(path); + } + } + files.into_iter().collect() +} + +fn resolve_include_directories(base_dir: &Path, raw_files: &[FireFileRaw]) -> Vec { + let mut directories = BTreeSet::new(); + + for raw in raw_files { + for include in &raw.include { + let trimmed = include.trim(); + if trimmed.is_empty() { + continue; + } + + let Some(relative) = normalize_relative_include_path(trimmed) else { + eprintln!("[fire] Invalid include path '{trimmed}'. Skipping."); + continue; + }; + + let path = base_dir.join(relative); + if !path.is_dir() { + eprintln!( + "[fire] Include directory '{}' does not exist. Skipping.", + path.display() + ); + continue; + } + + directories.insert(path); + } + } + + directories.into_iter().collect() +} + +fn normalize_relative_include_path(path: &str) -> Option { + let raw = Path::new(path); + if raw.is_absolute() { + return None; + } + + let mut normalized = PathBuf::new(); + for component in raw.components() { + match component { + Component::Normal(segment) => normalized.push(segment), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, + } + } + + if normalized.as_os_str().is_empty() { + return None; + } + + Some(normalized) +} + #[cfg(test)] mod tests { use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + let dir = + std::env::temp_dir().join(format!("fire-{prefix}-{}-{nanos}", std::process::id())); + fs::create_dir_all(&dir).expect("create temp dir"); + dir + } #[test] fn missing_namespace_uses_directory_explicit_prefix() { let raw = FireFileRaw::default(); - let scope = scope_from_raw(&raw, Some("ex")); + let scope = scope_from_raw(&raw, Some("ex"), None); match scope { FileScope::Namespace { namespace, .. } => assert_eq!(namespace, "ex"), _ => panic!("expected namespace scope"), @@ -318,7 +452,7 @@ mod tests { #[test] fn missing_namespace_without_directory_prefix_stays_root() { let raw = FireFileRaw::default(); - let scope = scope_from_raw(&raw, None); + let scope = scope_from_raw(&raw, None, None); match scope { FileScope::Root => {} _ => panic!("expected root scope"), @@ -334,11 +468,13 @@ mod tests { prefix: "ex".to_string(), description: String::new(), }), + include: Vec::new(), commands: BTreeMap::new(), }, FireFileRaw { group: String::new(), namespace: None, + include: Vec::new(), commands: BTreeMap::new(), }, ]; @@ -353,4 +489,156 @@ mod tests { let selected = select_local_default_namespace_prefix(&files, &cwd); assert_eq!(selected, "my-project".to_string()); } + + #[test] + fn included_file_uses_forced_root_namespace() { + let raw = FireFileRaw { + group: "backend".to_string(), + namespace: Some(NamespaceRaw { + prefix: "custom".to_string(), + description: "Custom".to_string(), + }), + include: Vec::new(), + commands: BTreeMap::new(), + }; + let forced = NamespaceScope { + prefix: "ex".to_string(), + description: "Example".to_string(), + }; + + let scope = scope_from_raw(&raw, None, Some(&forced)); + match scope { + FileScope::NamespaceGroup { + namespace, + namespace_description, + group, + } => { + assert_eq!(namespace, "ex"); + assert_eq!(namespace_description, "Example"); + assert_eq!(group, "backend"); + } + _ => panic!("expected namespace group scope"), + } + } + + #[test] + fn include_paths_must_be_relative_and_non_parent() { + assert!(normalize_relative_include_path("samples/").is_some()); + assert!(normalize_relative_include_path("./samples").is_some()); + assert!(normalize_relative_include_path("../samples").is_none()); + assert!(normalize_relative_include_path("/abs").is_none()); + assert!(normalize_relative_include_path("").is_none()); + } + + #[test] + fn local_load_includes_directories_without_recursion_and_inherits_namespace() { + let root = unique_temp_dir("local-include"); + let samples_dir = root.join("samples"); + let nested_dir = samples_dir.join("nested"); + fs::create_dir_all(&nested_dir).expect("create include dirs"); + + fs::write( + root.join("fire.yml"), + r#" +namespace: + prefix: ex + description: Example +include: + - samples/ +commands: + run: + exec: npm run +"#, + ) + .expect("write root file"); + + fs::write( + samples_dir.join("deploy.fire.yml"), + r#" +group: backend +namespace: + prefix: ignored + description: Ignored +commands: + build: + exec: npm run build +"#, + ) + .expect("write included file"); + + fs::write( + nested_dir.join("ignored.fire.yml"), + r#" +commands: + deep: + exec: echo deep +"#, + ) + .expect("write nested file"); + + let files = load_local_file_configs(&root, SourceKind::Local); + let has_build = files.iter().any(|file| file.commands.contains_key("build")); + let has_deep = files.iter().any(|file| file.commands.contains_key("deep")); + assert!(has_build); + assert!(!has_deep); + + let backend_scope = files + .iter() + .find(|file| file.commands.contains_key("build")) + .map(|file| file.scope.clone()) + .expect("backend scope"); + + match backend_scope { + FileScope::NamespaceGroup { + namespace, + namespace_description, + group, + } => { + assert_eq!(namespace, "ex"); + assert_eq!(namespace_description, "Example"); + assert_eq!(group, "backend"); + } + _ => panic!("expected namespace group"), + } + + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn global_load_includes_directories_without_extra_install_step() { + let root = unique_temp_dir("global-include"); + let samples_dir = root.join("samples"); + fs::create_dir_all(&samples_dir).expect("create include dirs"); + + fs::write( + root.join("fire.yml"), + r#" +include: + - samples/ +commands: + root: + exec: echo root +"#, + ) + .expect("write root file"); + + fs::write( + samples_dir.join("test.fire.yml"), + r#" +commands: + test: + exec: echo test +"#, + ) + .expect("write included file"); + + let files = load_global_file_configs(&root, SourceKind::Global); + let has_root = files.iter().any(|file| file.commands.contains_key("root")); + let has_test = files.iter().any(|file| file.commands.contains_key("test")); + + assert!(has_root); + assert!(has_test); + + fs::remove_dir_all(root).expect("cleanup"); + } } From 2a1026d059828479d1de52d174e1daf9b53d7a2d Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Sun, 1 Mar 2026 00:55:21 -0600 Subject: [PATCH 03/10] init command --- README.md | 6 ++ src/cli.rs | 253 ++++++++++++++++++++++++++++++++++++++++++++++ src/completion.rs | 57 ++++++++++- src/lib.rs | 34 +------ 4 files changed, 317 insertions(+), 33 deletions(-) create mode 100644 src/cli.rs diff --git a/README.md b/README.md index de5cc2f..95b8dba 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,12 @@ Install the current directory globally: fire cli install ``` +Create a minimal config with an interactive wizard: + +```bash +fire cli init +``` + Behavior: - Stores only the absolute directory path (no command cache, no file copy). - Avoids duplicates if the path is already installed. diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..ded2e75 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,253 @@ +use std::{ + fs, + io::{self, Write}, + path::PathBuf, + process, +}; + +use crate::registry::{install_directory, InstallResult}; + +pub(crate) fn handle_cli_command(command_args: &[String]) { + match command_args { + [cli, install] if cli == "cli" && install == "install" => run_install(), + [cli, init] if cli == "cli" && init == "init" => run_init(), + [cli] if cli == "cli" => print_cli_help(), + _ => { + eprintln!("[fire] Unknown cli command"); + eprintln!("Usage:"); + eprintln!(" fire cli install"); + eprintln!(" fire cli init"); + process::exit(1); + } + } +} + +fn run_install() { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + match install_directory(&cwd) { + Ok(InstallResult::Added) => { + println!("Installed directory: {}", cwd.display()); + } + Ok(InstallResult::AlreadyInstalled) => { + println!("Directory already installed: {}", cwd.display()); + } + Err(err) => { + eprintln!("[fire] Failed to install directory: {err}"); + process::exit(1); + } + } +} + +fn run_init() { + println!("Fire CLI Init"); + println!("----------------------------------------"); + println!("Create a minimal fire config file for this directory."); + println!(); + + let base_name = prompt_base_file_name(); + let file_name = file_name_from_base(&base_name); + + println!(); + println!("Namespace"); + println!("A namespace can separate companies or a full product/service."); + let namespace_prefix = prompt_line("Namespace prefix (optional):"); + + let namespace_description = if namespace_prefix.is_empty() { + String::new() + } else { + prompt_non_empty_line("Namespace description:") + }; + + println!(); + println!("Group"); + println!("A group can separate areas like backend, frontend, or data."); + let group = prompt_line("Command group (optional):"); + + let output_path = PathBuf::from(&file_name); + if output_path.exists() && !confirm_overwrite(&file_name) { + println!("Init cancelled. File not modified."); + return; + } + + let content = build_init_yaml( + non_empty(&namespace_prefix), + non_empty(&namespace_description), + non_empty(&group), + ); + + if let Err(err) = fs::write(&output_path, content) { + eprintln!("[fire] Failed to write {}: {err}", output_path.display()); + process::exit(1); + } + + println!(); + println!("Created {}", output_path.display()); + println!("Try it with:"); + println!(" fire example"); +} + +fn print_cli_help() { + println!("Fire CLI Management"); + println!("Commands:"); + println!(" install Register the current directory for global command loading"); + println!(" init Create a minimal fire config file with guided prompts"); +} + +fn prompt_base_file_name() -> String { + loop { + let value = prompt_line("Base file name (backend/common/database). Empty -> fire.yml:"); + if value.is_empty() { + return value; + } + + let normalized = strip_known_yaml_suffixes(&value); + if is_valid_file_base(normalized) { + return value; + } + + println!("Invalid base name. Use letters, numbers, '-' or '_' only."); + } +} + +fn prompt_non_empty_line(label: &str) -> String { + loop { + let value = prompt_line(label); + if !value.is_empty() { + return value; + } + println!("This field is required."); + } +} + +fn prompt_line(label: &str) -> String { + print!("{label} "); + let _ = io::stdout().flush(); + + let mut value = String::new(); + if io::stdin().read_line(&mut value).is_err() { + return String::new(); + } + value.trim().to_string() +} + +fn confirm_overwrite(file_name: &str) -> bool { + let answer = prompt_line(&format!( + "File '{file_name}' already exists. Overwrite? [y/N]:" + )); + matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") +} + +fn strip_known_yaml_suffixes(value: &str) -> &str { + value + .strip_suffix(".fire.yml") + .or_else(|| value.strip_suffix(".fire.yaml")) + .or_else(|| value.strip_suffix(".yml")) + .or_else(|| value.strip_suffix(".yaml")) + .unwrap_or(value) +} + +fn is_valid_file_base(value: &str) -> bool { + !value.is_empty() + && !value.contains('/') + && !value.contains('\\') + && !value.contains("..") + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') +} + +fn file_name_from_base(base: &str) -> String { + let trimmed = base.trim(); + if trimmed.is_empty() { + return "fire.yml".to_string(); + } + let normalized = strip_known_yaml_suffixes(trimmed); + format!("{normalized}.fire.yml") +} + +fn build_init_yaml( + namespace_prefix: Option<&str>, + namespace_description: Option<&str>, + group: Option<&str>, +) -> String { + let mut lines = Vec::new(); + lines.push( + "# yaml-language-server: $schema=https://raw.githubusercontent.com/gbenm/fire/main/schemas/fire.schema.json" + .to_string(), + ); + + if let Some(prefix) = namespace_prefix { + lines.push(String::new()); + lines.push("namespace:".to_string()); + lines.push(format!(" prefix: {}", yaml_quote(prefix))); + lines.push(format!( + " description: {}", + yaml_quote(namespace_description.unwrap_or_default()) + )); + } + + if let Some(group) = group { + lines.push(String::new()); + lines.push(format!("group: {}", yaml_quote(group))); + } + + lines.push(String::new()); + lines.push("commands:".to_string()); + lines.push(" example: echo \"hello world\"".to_string()); + lines.push(String::new()); + + lines.join("\n") +} + +fn yaml_quote(value: &str) -> String { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +fn non_empty(value: &str) -> Option<&str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +#[cfg(test)] +mod tests { + use super::{build_init_yaml, file_name_from_base, is_valid_file_base}; + + #[test] + fn empty_base_name_uses_fire_yml() { + assert_eq!(file_name_from_base(""), "fire.yml"); + } + + #[test] + fn base_name_generates_pattern_file_name() { + assert_eq!(file_name_from_base("backend"), "backend.fire.yml"); + assert_eq!(file_name_from_base("backend.fire.yml"), "backend.fire.yml"); + } + + #[test] + fn validates_base_name() { + assert!(is_valid_file_base("backend_api")); + assert!(!is_valid_file_base("backend/api")); + assert!(!is_valid_file_base("..")); + } + + #[test] + fn minimal_yaml_contains_example_command() { + let yaml = build_init_yaml(None, None, None); + assert!(yaml.contains("commands:")); + assert!(yaml.contains("example: echo \"hello world\"")); + } + + #[test] + fn yaml_contains_namespace_and_group_when_provided() { + let yaml = build_init_yaml(Some("ex"), Some("Example"), Some("backend")); + assert!(yaml.contains("namespace:")); + assert!(yaml.contains("prefix: \"ex\"")); + assert!(yaml.contains("description: \"Example\"")); + assert!(yaml.contains("group: \"backend\"")); + } +} diff --git a/src/completion.rs b/src/completion.rs index 1470efa..5eb070d 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -66,6 +66,7 @@ pub(crate) fn render_values_only(suggestions: &[CompletionSuggestion]) -> Vec Vec { + let core = core_root_commands(prefix); let local_commands = local_commands(config, prefix); let local_namespaces = local_namespaces(config, prefix); let local_groups = local_groups(config, prefix); @@ -73,6 +74,7 @@ fn root_suggestions(config: &LoadedConfig, prefix: &str) -> Vec Vec Vec { + let core_children = core_command_children(value, ""); + if !core_children.is_empty() { + return core_children; + } + let command_children = root_command_children(config, value); if !command_children.is_empty() { return command_children; @@ -103,6 +110,11 @@ fn children_for_path( ) -> Vec { if path.len() == 1 { let head = &path[0]; + let core_children = core_command_children(head, prefix); + if !core_children.is_empty() { + return core_children; + } + let command_children = root_command_children(config, head); if !command_children.is_empty() { return filter_prefix(prefix, command_children); @@ -120,6 +132,41 @@ fn children_for_path( filter_prefix(prefix, candidates) } +fn core_root_commands(prefix: &str) -> Vec { + let command = CompletionSuggestion { + value: "cli".to_string(), + description: Some("Fire CLI management commands".to_string()), + }; + + if command.value.starts_with(prefix) { + vec![command] + } else { + Vec::new() + } +} + +fn core_command_children(command: &str, prefix: &str) -> Vec { + if command != "cli" { + return Vec::new(); + } + + let suggestions = vec![ + CompletionSuggestion { + value: "install".to_string(), + description: Some("Register current directory globally".to_string()), + }, + CompletionSuggestion { + value: "init".to_string(), + description: Some("Create a minimal fire config file".to_string()), + }, + ]; + + suggestions + .into_iter() + .filter(|suggestion| suggestion.value.starts_with(prefix)) + .collect() +} + fn children_for_exact_path(config: &LoadedConfig, path: &[String]) -> Vec { if path.is_empty() { return root_suggestions(config, ""); @@ -664,7 +711,7 @@ commands: let config = config_with_scopes(); let values = completion_suggestions(&config, &[]); let names: Vec = values.into_iter().map(|it| it.value).collect(); - assert_eq!(names, vec!["dev", "run", "ex", "backend", "ping"]); + assert_eq!(names, vec!["cli", "dev", "run", "ex", "backend", "ping"]); } #[test] @@ -743,4 +790,12 @@ commands: vec!["run\trun service".to_string()] ); } + + #[test] + fn core_cli_lists_subcommands() { + let config = config_with_scopes(); + let values = completion_suggestions(&config, &["cli".to_string()]); + let names: Vec = values.into_iter().map(|it| it.value).collect(); + assert_eq!(names, vec!["install", "init"]); + } } diff --git a/src/lib.rs b/src/lib.rs index aca4c24..9dc5c00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ use std::{env, path::Path, process}; +mod cli; mod completion; mod config; mod execute; @@ -7,11 +8,11 @@ mod help; mod registry; mod resolve; +use cli::handle_cli_command; use completion::{completion_suggestions, render_values_only, render_with_descriptions}; use config::load_config; use execute::execute_resolved_command; use help::{print_command_help, print_root_help, print_scope_help}; -use registry::{install_directory, InstallResult}; use resolve::resolve_command; pub fn setup_cli() { @@ -93,37 +94,6 @@ pub fn setup_cli() { process::exit(1); } -fn handle_cli_command(command_args: &[String]) { - match command_args { - [cli, install] if cli == "cli" && install == "install" => { - let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - match install_directory(&cwd) { - Ok(InstallResult::Added) => { - println!("Installed directory: {}", cwd.display()); - } - Ok(InstallResult::AlreadyInstalled) => { - println!("Directory already installed: {}", cwd.display()); - } - Err(err) => { - eprintln!("[fire] Failed to install directory: {err}"); - process::exit(1); - } - } - } - [cli] if cli == "cli" => { - println!("Fire CLI Management"); - println!("Commands:"); - println!(" install Register the current directory for global command loading"); - } - _ => { - eprintln!("[fire] Unknown cli command"); - eprintln!("Usage:"); - eprintln!(" fire cli install"); - process::exit(1); - } - } -} - fn normalize_completion_words(mut words: Vec, bin_name: &str) -> Vec { if words.first().map(String::as_str) == Some("--") { words.remove(0); From c4b931e24fd26e12ba69961ba273b2a05b74bf11 Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Sun, 1 Mar 2026 01:26:36 -0600 Subject: [PATCH 04/10] runner, fallback_runner, check support --- compose.yml | 6 + config.sample.fire.yml | 24 ++- src/completion.rs | 8 + src/config.rs | 27 ++- src/execute.rs | 364 ++++++++++++++++++++++++++++++++++++++--- src/help.rs | 5 +- src/resolve.rs | 13 +- 7 files changed, 424 insertions(+), 23 deletions(-) create mode 100644 compose.yml diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..cdcb241 --- /dev/null +++ b/compose.yml @@ -0,0 +1,6 @@ +services: + linux: + image: alpine:3.19 + container_name: fire-alpine-svc + command: ["tail", "-f", "/dev/null"] + restart: unless-stopped diff --git a/config.sample.fire.yml b/config.sample.fire.yml index 97db877..e81c39d 100644 --- a/config.sample.fire.yml +++ b/config.sample.fire.yml @@ -1,5 +1,5 @@ # yaml-language-server: $schema=./schemas/fire.schema.json -dir: . # default working directory +dir: . # default working directory "." env_file: .env # default env file @@ -118,5 +118,27 @@ commands: exec: npm run start: fire build && npm run start + fallback: + check: exit -1 + fallback_runner: docker run --rm -it -v .:/mount alpine sh + exec: + - cd mount + - pwd + - ls + - cat README.md + + alpine: + runner: docker compose exec linux sh + exec: + - echo "This is running inside the alpine container!" + - pwd + + container: + runner: docker run --rm -it -v .:/mount alpine sh + exec: + - cd /mount + - pwd + - ls + web: delegate: npm-run # built-in delegate that uses package.json scripts for autocompletion diff --git a/src/completion.rs b/src/completion.rs index 5eb070d..49d37d0 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -614,6 +614,8 @@ fn non_empty(value: &str) -> Option { #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::*; use crate::config::{FileConfig, FileScope, SourceKind}; @@ -632,6 +634,7 @@ mod tests { files: vec![ FileConfig { source: SourceKind::Local, + project_dir: PathBuf::from("."), scope: FileScope::Root, commands: commands( r#" @@ -647,6 +650,7 @@ commands: }, FileConfig { source: SourceKind::Global, + project_dir: PathBuf::from("."), scope: FileScope::Namespace { namespace: "ex".to_string(), namespace_description: "example namespace".to_string(), @@ -662,6 +666,7 @@ commands: }, FileConfig { source: SourceKind::Global, + project_dir: PathBuf::from("."), scope: FileScope::Group { group: "backend".to_string(), }, @@ -676,6 +681,7 @@ commands: }, FileConfig { source: SourceKind::Global, + project_dir: PathBuf::from("."), scope: FileScope::Root, commands: commands( r#" @@ -688,6 +694,7 @@ commands: }, FileConfig { source: SourceKind::Global, + project_dir: PathBuf::from("."), scope: FileScope::NamespaceGroup { namespace: "ex".to_string(), namespace_description: String::new(), @@ -756,6 +763,7 @@ commands: let config = LoadedConfig { files: vec![FileConfig { source: SourceKind::Local, + project_dir: PathBuf::from("."), scope: FileScope::NamespaceGroup { namespace: "ex".to_string(), namespace_description: String::new(), diff --git a/src/config.rs b/src/config.rs index cff1be5..f8ffaa6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,7 @@ pub(crate) enum SourceKind { #[derive(Debug, Clone)] pub(crate) struct FileConfig { pub(crate) source: SourceKind, + pub(crate) project_dir: PathBuf, pub(crate) scope: FileScope, pub(crate) commands: BTreeMap, } @@ -55,6 +56,14 @@ pub(crate) struct CommandSpec { #[serde(default)] pub(crate) description: String, #[serde(default)] + pub(crate) dir: String, + #[serde(default)] + pub(crate) check: String, + #[serde(default)] + pub(crate) runner: String, + #[serde(default)] + pub(crate) fallback_runner: String, + #[serde(default)] pub(crate) exec: Option, #[serde(default)] pub(crate) run: Option, @@ -120,6 +129,13 @@ impl CommandEntry { CommandEntry::Spec(spec) => Some(&spec.commands), } } + + pub(crate) fn spec(&self) -> Option<&CommandSpec> { + match self { + CommandEntry::Shorthand(_) => None, + CommandEntry::Spec(spec) => Some(spec), + } + } } impl CommandAction { @@ -189,10 +205,17 @@ fn load_directory_file_configs( let include_paths = discover_config_files_from_dirs(&include_dirs); let include_raw = parse_raw_files(&include_paths); - let mut files = to_file_configs(&root_raw, source, default_namespace.as_deref(), None); + let mut files = to_file_configs( + &root_raw, + source, + base_dir, + default_namespace.as_deref(), + None, + ); files.extend(to_file_configs( &include_raw, source, + base_dir, default_namespace.as_deref(), explicit_namespace.as_ref(), )); @@ -225,6 +248,7 @@ fn parse_raw_files(paths: &[PathBuf]) -> Vec { fn to_file_configs( raw_files: &[FireFileRaw], source: SourceKind, + project_dir: &Path, default_namespace_prefix: Option<&str>, forced_namespace: Option<&NamespaceScope>, ) -> Vec { @@ -232,6 +256,7 @@ fn to_file_configs( .iter() .map(|raw| FileConfig { source, + project_dir: project_dir.to_path_buf(), scope: scope_from_raw(raw, default_namespace_prefix, forced_namespace), commands: raw.commands.clone(), }) diff --git a/src/execute.rs b/src/execute.rs index 48fec57..8d6a312 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -1,4 +1,9 @@ -use std::{process, process::Command}; +use std::{ + io::Write, + path::{Path, PathBuf}, + process, + process::{Command, Stdio}, +}; use crate::resolve::ResolvedCommand; @@ -26,31 +31,188 @@ pub(crate) fn execute_resolved_command(resolved: ResolvedCommand<'_>) -> ! { process::exit(1); }; + let context = build_execution_context(&resolved); + ensure_working_directory(&context.dir); + + let selected_runner = select_runner(&context); let mut exit_code = 0; - for (index, command) in commands_to_run.iter().enumerate() { - let mut full_command = command.clone(); - if index + 1 == commands_to_run.len() && !resolved.remaining_args.is_empty() { - full_command.push(' '); - full_command.push_str(&join_shell_args(resolved.remaining_args)); + let commands_to_run = commands_with_remaining_args(&commands_to_run, resolved.remaining_args); + + match selected_runner { + Some(runner) => { + exit_code = run_with_runner(&runner, &context.dir, &commands_to_run); + } + None => { + for command in &commands_to_run { + let status = run_shell_command(command, &context.dir); + let code = status.code().unwrap_or(1); + exit_code = code; + if code != 0 { + break; + } + } } + } - let status = Command::new("sh") - .arg("-c") - .arg(&full_command) - .status() - .unwrap_or_else(|err| { - eprintln!("[fire] Failed to execute `{full_command}`: {err}"); - process::exit(1); - }); + process::exit(exit_code); +} + +#[derive(Debug, Default)] +struct ExecutionContext { + dir: PathBuf, + runner: Option, + fallback_runner: Option, + check: Option, +} - let code = status.code().unwrap_or(1); - exit_code = code; - if code != 0 { - break; +fn build_execution_context(resolved: &ResolvedCommand<'_>) -> ExecutionContext { + let mut context = ExecutionContext { + dir: resolved.project_dir.to_path_buf(), + ..ExecutionContext::default() + }; + + for entry in &resolved.command_chain { + let Some(spec) = entry.spec() else { + continue; + }; + + if let Some(next_dir) = non_empty(&spec.dir) { + context.dir = resolve_next_dir(&context.dir, next_dir); + } + if let Some(check) = non_empty(&spec.check) { + context.check = Some(check.to_string()); + } + if let Some(runner) = non_empty(&spec.runner) { + context.runner = Some(runner.to_string()); + } + if let Some(fallback_runner) = non_empty(&spec.fallback_runner) { + context.fallback_runner = Some(fallback_runner.to_string()); } } - process::exit(exit_code); + context +} + +fn resolve_next_dir(current: &Path, next: &str) -> PathBuf { + let next_path = Path::new(next); + if next_path.is_absolute() { + next_path.to_path_buf() + } else { + current.join(next_path) + } +} + +fn ensure_working_directory(dir: &Path) { + if !dir.exists() { + eprintln!("[fire] Working directory does not exist: {}", dir.display()); + process::exit(1); + } + if !dir.is_dir() { + eprintln!( + "[fire] Working directory is not a directory: {}", + dir.display() + ); + process::exit(1); + } +} + +fn select_runner(context: &ExecutionContext) -> Option { + let check_passed = context + .check + .as_deref() + .map(|check| run_shell_command(check, &context.dir).success()) + .unwrap_or(true); + + if check_passed { + return context.runner.clone(); + } + + if let Some(fallback) = context.fallback_runner.clone() { + return Some(fallback); + } + + if context.check.is_some() { + eprintln!("[fire] Check command failed and no fallback_runner is configured."); + process::exit(1); + } + + context.runner.clone() +} + +fn run_with_runner(runner: &str, dir: &Path, commands: &[String]) -> i32 { + let normalized_runner = normalize_runner_for_piped_stdin(runner); + let mut child = Command::new("sh") + .arg("-c") + .arg(&normalized_runner) + .current_dir(dir) + .stdin(Stdio::piped()) + .spawn() + .unwrap_or_else(|err| { + eprintln!("[fire] Failed to start runner `{normalized_runner}`: {err}"); + process::exit(1); + }); + + { + let Some(stdin) = child.stdin.as_mut() else { + eprintln!("[fire] Runner `{normalized_runner}` has no writable stdin."); + let _ = child.kill(); + process::exit(1); + }; + + if writeln!(stdin, "set -e").is_err() { + eprintln!("[fire] Failed to initialize runner shell."); + let _ = child.kill(); + process::exit(1); + } + + for command in commands { + if writeln!(stdin, "{command}").is_err() { + eprintln!("[fire] Failed to send command to runner: `{command}`"); + let _ = child.kill(); + process::exit(1); + } + } + + let _ = writeln!(stdin, "exit"); + } + + let status = child.wait().unwrap_or_else(|err| { + eprintln!("[fire] Failed while waiting for runner `{normalized_runner}`: {err}"); + let _ = child.kill(); + process::exit(1); + }); + + if !status.success() { + return status.code().unwrap_or(1); + } + + status.code().unwrap_or(0) +} + +fn commands_with_remaining_args(commands: &[String], remaining_args: &[String]) -> Vec { + let mut out = commands.to_vec(); + if out.is_empty() || remaining_args.is_empty() { + return out; + } + + if let Some(last) = out.last_mut() { + last.push(' '); + last.push_str(&join_shell_args(remaining_args)); + } + + out +} + +fn run_shell_command(command: &str, dir: &Path) -> process::ExitStatus { + Command::new("sh") + .arg("-c") + .arg(command) + .current_dir(dir) + .status() + .unwrap_or_else(|err| { + eprintln!("[fire] Failed to execute `{command}`: {err}"); + process::exit(1); + }) } fn join_shell_args(args: &[String]) -> String { @@ -76,12 +238,176 @@ fn shell_escape(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } +fn non_empty(value: &str) -> Option<&str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn normalize_runner_for_piped_stdin(runner: &str) -> String { + // Commands are sent through stdin. In that mode, explicit TTY flags break + // tools like docker with "the input device is not a TTY". + let mut out = Vec::new(); + for token in runner.split_whitespace() { + if token == "-t" || token == "--tty" { + continue; + } + if token == "-it" || token == "-ti" { + out.push("-i".to_string()); + continue; + } + if token.starts_with('-') && !token.starts_with("--") && token.len() > 2 { + let mut chars = token.chars(); + let dash = chars.next().unwrap_or('-'); + let flags: String = chars.filter(|ch| *ch != 't').collect(); + if flags.is_empty() { + continue; + } + out.push(format!("{dash}{flags}")); + continue; + } + out.push(token.to_string()); + } + + ensure_non_tty_for_docker_compose_exec(&mut out); + + if out.is_empty() { + runner.to_string() + } else { + out.join(" ") + } +} + +fn ensure_non_tty_for_docker_compose_exec(tokens: &mut Vec) { + if tokens.is_empty() { + return; + } + + let exec_index = if tokens.first().map(String::as_str) == Some("docker-compose") { + tokens.iter().position(|token| token == "exec") + } else if tokens.len() >= 2 + && tokens.first().map(String::as_str) == Some("docker") + && tokens.get(1).map(String::as_str) == Some("compose") + { + tokens.iter().position(|token| token == "exec") + } else { + None + }; + + let Some(exec_index) = exec_index else { + return; + }; + + if tokens + .iter() + .any(|token| token == "-T" || token == "--no-tty") + { + return; + } + + tokens.insert(exec_index + 1, "-T".to_string()); +} + #[cfg(test)] mod tests { use super::*; + use crate::config::{CommandEntry, CommandSpec}; #[test] fn escape_single_quote_in_shell_argument() { assert_eq!(shell_escape("it'ok"), "'it'\"'\"'ok'"); } + + #[test] + fn nested_relative_dirs_are_resolved_from_parent() { + let root = PathBuf::from("/tmp/project"); + let child = resolve_next_dir(&root, "services"); + let nested = resolve_next_dir(&child, "api"); + assert_eq!(nested, PathBuf::from("/tmp/project/services/api")); + } + + #[test] + fn absolute_dir_overrides_parent_dir() { + let root = PathBuf::from("/tmp/project"); + let nested = resolve_next_dir(&root, "/opt/workspace"); + assert_eq!(nested, PathBuf::from("/opt/workspace")); + } + + #[test] + fn remaining_args_only_append_to_last_command() { + let commands = vec!["npm run build".to_string(), "npm run start".to_string()]; + let result = + commands_with_remaining_args(&commands, &["--host".to_string(), "0.0.0.0".to_string()]); + assert_eq!( + result, + vec![ + "npm run build".to_string(), + "npm run start --host 0.0.0.0".to_string() + ] + ); + } + + #[test] + fn select_runner_uses_fallback_when_check_fails() { + let context = ExecutionContext { + dir: std::env::current_dir().expect("cwd"), + runner: Some("bash".to_string()), + fallback_runner: Some("sh".to_string()), + check: Some("false".to_string()), + }; + let selected = select_runner(&context); + assert_eq!(selected, Some("sh".to_string())); + } + + #[test] + fn select_runner_uses_primary_when_check_passes() { + let context = ExecutionContext { + dir: std::env::current_dir().expect("cwd"), + runner: Some("bash".to_string()), + fallback_runner: Some("sh".to_string()), + check: Some("true".to_string()), + }; + let selected = select_runner(&context); + assert_eq!(selected, Some("bash".to_string())); + } + + #[test] + fn command_entry_spec_is_available_for_spec_variant() { + let entry = CommandEntry::Spec(CommandSpec { + dir: "api".to_string(), + ..CommandSpec::default() + }); + assert!(entry.spec().is_some()); + } + + #[test] + fn normalizes_tty_flags_for_piped_runner() { + let runner = "docker run --rm -it node:lts-alpine /bin/bash"; + let normalized = normalize_runner_for_piped_stdin(runner); + assert_eq!(normalized, "docker run --rm -i node:lts-alpine /bin/bash"); + } + + #[test] + fn keeps_non_tty_flags_untouched() { + let runner = "docker exec -i my-container /bin/sh"; + let normalized = normalize_runner_for_piped_stdin(runner); + assert_eq!(normalized, runner); + } + + #[test] + fn docker_compose_exec_adds_no_tty_flag() { + let runner = "docker compose exec linux sh"; + let normalized = normalize_runner_for_piped_stdin(runner); + assert_eq!(normalized, "docker compose exec -T linux sh"); + } + + #[test] + fn docker_compose_exec_keeps_existing_no_tty_flag() { + let runner = "docker compose exec -T linux sh"; + let normalized = normalize_runner_for_piped_stdin(runner); + assert_eq!(normalized, runner); + } } diff --git a/src/help.rs b/src/help.rs index 8267819..da8d3f7 100644 --- a/src/help.rs +++ b/src/help.rs @@ -305,7 +305,7 @@ fn non_empty(value: &str) -> Option { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::{collections::BTreeMap, path::PathBuf}; use crate::config::{CommandEntry, FileConfig, FileScope, LoadedConfig, SourceKind}; @@ -326,6 +326,7 @@ mod tests { files: vec![ FileConfig { source: SourceKind::Local, + project_dir: PathBuf::from("."), scope: FileScope::Root, commands: parse_commands( r#" @@ -338,6 +339,7 @@ commands: }, FileConfig { source: SourceKind::Global, + project_dir: PathBuf::from("."), scope: FileScope::Namespace { namespace: "ex".to_string(), namespace_description: "Example".to_string(), @@ -353,6 +355,7 @@ commands: }, FileConfig { source: SourceKind::Global, + project_dir: PathBuf::from("."), scope: FileScope::NamespaceGroup { namespace: "ex".to_string(), namespace_description: "Example".to_string(), diff --git a/src/resolve.rs b/src/resolve.rs index 7c36384..08435c0 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -1,7 +1,11 @@ +use std::path::Path; + use crate::config::{CommandEntry, FileScope, LoadedConfig}; pub(crate) struct ResolvedCommand<'a> { + pub(crate) project_dir: &'a Path, pub(crate) command: &'a CommandEntry, + pub(crate) command_chain: Vec<&'a CommandEntry>, pub(crate) consumed: usize, pub(crate) remaining_args: &'a [String], } @@ -20,6 +24,7 @@ pub(crate) fn resolve_command<'a>( let mut consumed = base_consumed; let mut current = command_entry; + let mut chain = vec![command_entry]; while consumed < args.len() { let Some(subcommands) = current.subcommands() else { @@ -27,6 +32,7 @@ pub(crate) fn resolve_command<'a>( }; if let Some(next) = subcommands.get(&args[consumed]) { current = next; + chain.push(current); consumed += 1; continue; } @@ -34,7 +40,9 @@ pub(crate) fn resolve_command<'a>( } let candidate = ResolvedCommand { + project_dir: &file.project_dir, command: current, + command_chain: chain, consumed, remaining_args: &args[consumed..], }; @@ -117,7 +125,7 @@ fn better_than( #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::{collections::BTreeMap, path::PathBuf}; use crate::config::{CommandEntry, FileConfig, FileScope, LoadedConfig, SourceKind}; @@ -145,6 +153,7 @@ commands: let config = LoadedConfig { files: vec![FileConfig { source: SourceKind::Local, + project_dir: PathBuf::from("."), scope: FileScope::Root, commands: parse_commands(yaml), }], @@ -167,6 +176,7 @@ commands: let config = LoadedConfig { files: vec![FileConfig { source: SourceKind::Global, + project_dir: PathBuf::from("."), scope: FileScope::NamespaceGroup { namespace: "ex".to_string(), namespace_description: String::new(), @@ -198,6 +208,7 @@ commands: let config = LoadedConfig { files: vec![FileConfig { source: SourceKind::Global, + project_dir: PathBuf::from("."), scope: FileScope::NamespaceGroup { namespace: "ex".to_string(), namespace_description: String::new(), From ee63ab97411e9db3689b4e9a6c9b891d7f189698 Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Sun, 1 Mar 2026 01:35:58 -0600 Subject: [PATCH 05/10] before support --- config.sample.fire.yml | 2 + src/config.rs | 2 + src/execute.rs | 83 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/config.sample.fire.yml b/config.sample.fire.yml index e81c39d..4a0bbdb 100644 --- a/config.sample.fire.yml +++ b/config.sample.fire.yml @@ -120,6 +120,7 @@ commands: fallback: check: exit -1 + before: echo "ignore me please" fallback_runner: docker run --rm -it -v .:/mount alpine sh exec: - cd mount @@ -129,6 +130,7 @@ commands: alpine: runner: docker compose exec linux sh + before: docker compose ps -q linux | grep -q . || docker compose up -d linux exec: - echo "This is running inside the alpine container!" - pwd diff --git a/src/config.rs b/src/config.rs index f8ffaa6..f286c4d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,6 +56,8 @@ pub(crate) struct CommandSpec { #[serde(default)] pub(crate) description: String, #[serde(default)] + pub(crate) before: String, + #[serde(default)] pub(crate) dir: String, #[serde(default)] pub(crate) check: String, diff --git a/src/execute.rs b/src/execute.rs index 8d6a312..18c724a 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -34,15 +34,28 @@ pub(crate) fn execute_resolved_command(resolved: ResolvedCommand<'_>) -> ! { let context = build_execution_context(&resolved); ensure_working_directory(&context.dir); - let selected_runner = select_runner(&context); + let selected_runner = select_runner_mode(&context); + let execute_before = should_execute_before(&selected_runner); let mut exit_code = 0; let commands_to_run = commands_with_remaining_args(&commands_to_run, resolved.remaining_args); match selected_runner { - Some(runner) => { + RunnerMode::Runner(runner) => { + if execute_before { + if let Some(before) = context.before.as_deref() { + let status = run_shell_command(before, &context.dir); + let code = status.code().unwrap_or(1); + if code != 0 { + process::exit(code); + } + } + } + exit_code = run_with_runner(&runner, &context.dir, &commands_to_run); + } + RunnerMode::Fallback(runner) => { exit_code = run_with_runner(&runner, &context.dir, &commands_to_run); } - None => { + RunnerMode::Direct => { for command in &commands_to_run { let status = run_shell_command(command, &context.dir); let code = status.code().unwrap_or(1); @@ -57,14 +70,26 @@ pub(crate) fn execute_resolved_command(resolved: ResolvedCommand<'_>) -> ! { process::exit(exit_code); } +fn should_execute_before(mode: &RunnerMode) -> bool { + matches!(mode, RunnerMode::Runner(_)) +} + #[derive(Debug, Default)] struct ExecutionContext { + before: Option, dir: PathBuf, runner: Option, fallback_runner: Option, check: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum RunnerMode { + Direct, + Runner(String), + Fallback(String), +} + fn build_execution_context(resolved: &ResolvedCommand<'_>) -> ExecutionContext { let mut context = ExecutionContext { dir: resolved.project_dir.to_path_buf(), @@ -79,6 +104,9 @@ fn build_execution_context(resolved: &ResolvedCommand<'_>) -> ExecutionContext { if let Some(next_dir) = non_empty(&spec.dir) { context.dir = resolve_next_dir(&context.dir, next_dir); } + if let Some(before) = non_empty(&spec.before) { + context.before = Some(before.to_string()); + } if let Some(check) = non_empty(&spec.check) { context.check = Some(check.to_string()); } @@ -116,7 +144,7 @@ fn ensure_working_directory(dir: &Path) { } } -fn select_runner(context: &ExecutionContext) -> Option { +fn select_runner_mode(context: &ExecutionContext) -> RunnerMode { let check_passed = context .check .as_deref() @@ -124,11 +152,14 @@ fn select_runner(context: &ExecutionContext) -> Option { .unwrap_or(true); if check_passed { - return context.runner.clone(); + if let Some(runner) = context.runner.clone() { + return RunnerMode::Runner(runner); + } + return RunnerMode::Direct; } if let Some(fallback) = context.fallback_runner.clone() { - return Some(fallback); + return RunnerMode::Fallback(fallback); } if context.check.is_some() { @@ -136,7 +167,11 @@ fn select_runner(context: &ExecutionContext) -> Option { process::exit(1); } - context.runner.clone() + if let Some(runner) = context.runner.clone() { + return RunnerMode::Runner(runner); + } + + RunnerMode::Direct } fn run_with_runner(runner: &str, dir: &Path, commands: &[String]) -> i32 { @@ -357,9 +392,10 @@ mod tests { runner: Some("bash".to_string()), fallback_runner: Some("sh".to_string()), check: Some("false".to_string()), + before: None, }; - let selected = select_runner(&context); - assert_eq!(selected, Some("sh".to_string())); + let selected = select_runner_mode(&context); + assert_eq!(selected, RunnerMode::Fallback("sh".to_string())); } #[test] @@ -369,9 +405,23 @@ mod tests { runner: Some("bash".to_string()), fallback_runner: Some("sh".to_string()), check: Some("true".to_string()), + before: None, + }; + let selected = select_runner_mode(&context); + assert_eq!(selected, RunnerMode::Runner("bash".to_string())); + } + + #[test] + fn select_runner_returns_direct_when_no_runner() { + let context = ExecutionContext { + dir: std::env::current_dir().expect("cwd"), + runner: None, + fallback_runner: None, + check: None, + before: Some("echo prep".to_string()), }; - let selected = select_runner(&context); - assert_eq!(selected, Some("bash".to_string())); + let selected = select_runner_mode(&context); + assert_eq!(selected, RunnerMode::Direct); } #[test] @@ -410,4 +460,15 @@ mod tests { let normalized = normalize_runner_for_piped_stdin(runner); assert_eq!(normalized, runner); } + + #[test] + fn before_runs_only_for_primary_runner() { + assert!(should_execute_before(&RunnerMode::Runner( + "bash".to_string() + ))); + assert!(!should_execute_before(&RunnerMode::Fallback( + "bash".to_string() + ))); + assert!(!should_execute_before(&RunnerMode::Direct)); + } } From 0066a6c9b7858bd746520c54c05e23d7ff3b0ef4 Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Sun, 1 Mar 2026 01:53:26 -0600 Subject: [PATCH 06/10] placeholder and macros support --- config.sample.fire.yml | 10 + src/config.rs | 6 + src/execute.rs | 602 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 565 insertions(+), 53 deletions(-) diff --git a/config.sample.fire.yml b/config.sample.fire.yml index 4a0bbdb..446b593 100644 --- a/config.sample.fire.yml +++ b/config.sample.fire.yml @@ -142,5 +142,15 @@ commands: - pwd - ls + template: + macros: + "CMD": docker compose exec linux + <<: *arg-config + exec: + - "CMD echo Hello {1}, are you from {2}?" + - "CMD echo {2} is a beautiful place!" + - "CMD echo and who else is with you? ...{{n}}" + - "CMD echo and who else is with you? [{{n}}]" + web: delegate: npm-run # built-in delegate that uses package.json scripts for autocompletion diff --git a/src/config.rs b/src/config.rs index f286c4d..2242d64 100644 --- a/src/config.rs +++ b/src/config.rs @@ -58,6 +58,12 @@ pub(crate) struct CommandSpec { #[serde(default)] pub(crate) before: String, #[serde(default)] + pub(crate) placeholder: String, + #[serde(default)] + pub(crate) on_unused_args: String, + #[serde(default)] + pub(crate) macros: BTreeMap, + #[serde(default)] pub(crate) dir: String, #[serde(default)] pub(crate) check: String, diff --git a/src/execute.rs b/src/execute.rs index 18c724a..ec06a07 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -1,4 +1,5 @@ use std::{ + collections::{BTreeMap, BTreeSet}, io::Write, path::{Path, PathBuf}, process, @@ -8,7 +9,7 @@ use std::{ use crate::resolve::ResolvedCommand; pub(crate) fn execute_resolved_command(resolved: ResolvedCommand<'_>) -> ! { - let Some(commands_to_run) = resolved.command.execution_commands() else { + let Some(raw_commands_to_run) = resolved.command.execution_commands() else { eprintln!("[fire] Command path has no executable action."); if let Some(subcommands) = resolved.command.subcommands() { eprintln!("Commands:"); @@ -34,25 +35,79 @@ pub(crate) fn execute_resolved_command(resolved: ResolvedCommand<'_>) -> ! { let context = build_execution_context(&resolved); ensure_working_directory(&context.dir); - let selected_runner = select_runner_mode(&context); - let execute_before = should_execute_before(&selected_runner); - let mut exit_code = 0; - let commands_to_run = commands_with_remaining_args(&commands_to_run, resolved.remaining_args); + let mut ignored_stats = RenderStats::default(); + let rendered_check = context.check.as_deref().map(|value| { + render_runtime_string( + value, + &context, + resolved.remaining_args, + false, + &mut ignored_stats, + ) + }); + let rendered_runner = context.runner.as_deref().map(|value| { + render_runtime_string( + value, + &context, + resolved.remaining_args, + false, + &mut ignored_stats, + ) + }); + let rendered_fallback_runner = context.fallback_runner.as_deref().map(|value| { + render_runtime_string( + value, + &context, + resolved.remaining_args, + false, + &mut ignored_stats, + ) + }); - match selected_runner { - RunnerMode::Runner(runner) => { - if execute_before { - if let Some(before) = context.before.as_deref() { - let status = run_shell_command(before, &context.dir); - let code = status.code().unwrap_or(1); - if code != 0 { - process::exit(code); - } - } + let selected_runner = select_runner_mode( + &context.dir, + rendered_check.as_deref(), + rendered_runner.as_deref(), + rendered_fallback_runner.as_deref(), + ); + + if should_execute_before(&selected_runner) { + if let Some(before) = context.before.as_deref() { + let rendered_before = render_runtime_string( + before, + &context, + resolved.remaining_args, + false, + &mut ignored_stats, + ); + let status = run_shell_command(&rendered_before, &context.dir); + let code = status.code().unwrap_or(1); + if code != 0 { + process::exit(code); } - exit_code = run_with_runner(&runner, &context.dir, &commands_to_run); } - RunnerMode::Fallback(runner) => { + } + + let mut render_stats = RenderStats::default(); + let rendered_commands_to_run = raw_commands_to_run + .iter() + .map(|command| { + render_runtime_string( + command, + &context, + resolved.remaining_args, + true, + &mut render_stats, + ) + }) + .collect::>(); + + let tail_args = unresolved_args_for_tail(&context, resolved.remaining_args, &render_stats); + let commands_to_run = commands_with_remaining_args(&rendered_commands_to_run, &tail_args); + + let mut exit_code = 0; + match selected_runner { + RunnerMode::Runner(runner) | RunnerMode::Fallback(runner) => { exit_code = run_with_runner(&runner, &context.dir, &commands_to_run); } RunnerMode::Direct => { @@ -81,6 +136,9 @@ struct ExecutionContext { runner: Option, fallback_runner: Option, check: Option, + placeholder: Option, + on_unused_args: Option, + macros: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -90,6 +148,19 @@ enum RunnerMode { Fallback(String), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UnusedArgsMode { + Ignore, + Warn, + Error, +} + +#[derive(Debug, Default)] +struct RenderStats { + used_arg_indexes: BTreeSet, + had_placeholders: bool, +} + fn build_execution_context(resolved: &ResolvedCommand<'_>) -> ExecutionContext { let mut context = ExecutionContext { dir: resolved.project_dir.to_path_buf(), @@ -116,11 +187,331 @@ fn build_execution_context(resolved: &ResolvedCommand<'_>) -> ExecutionContext { if let Some(fallback_runner) = non_empty(&spec.fallback_runner) { context.fallback_runner = Some(fallback_runner.to_string()); } + if let Some(placeholder) = non_empty(&spec.placeholder) { + context.placeholder = Some(placeholder.to_string()); + } + if let Some(on_unused_args) = non_empty(&spec.on_unused_args) { + context.on_unused_args = Some(parse_on_unused_args_mode(on_unused_args)); + } + for (macro_key, macro_value) in &spec.macros { + context + .macros + .insert(macro_key.clone(), macro_value.clone()); + } } context } +fn parse_on_unused_args_mode(value: &str) -> UnusedArgsMode { + match value { + "ignore" => UnusedArgsMode::Ignore, + "warn" => UnusedArgsMode::Warn, + "error" => UnusedArgsMode::Error, + _ => { + eprintln!( + "[fire] Invalid on_unused_args value `{value}`. Use one of: ignore, warn, error." + ); + process::exit(1); + } + } +} + +fn unresolved_args_for_tail( + context: &ExecutionContext, + remaining_args: &[String], + stats: &RenderStats, +) -> Vec { + if remaining_args.is_empty() { + return Vec::new(); + } + + let placeholder_configured = context.placeholder.is_some(); + let policy_configured = context.on_unused_args.is_some(); + + if !placeholder_configured && !policy_configured && !stats.had_placeholders { + return remaining_args.to_vec(); + } + + let unused_args = remaining_args + .iter() + .enumerate() + .filter_map(|(index, arg)| { + if stats.used_arg_indexes.contains(&index) { + None + } else { + Some(arg.clone()) + } + }) + .collect::>(); + + let mode = context.on_unused_args.unwrap_or(UnusedArgsMode::Error); + match mode { + UnusedArgsMode::Ignore => Vec::new(), + UnusedArgsMode::Warn => { + if !unused_args.is_empty() { + eprintln!( + "[fire] Warning: unused args ignored: {}", + join_shell_args(&unused_args) + ); + } + Vec::new() + } + UnusedArgsMode::Error => { + if !unused_args.is_empty() { + eprintln!( + "[fire] Unused args are not allowed: {}", + join_shell_args(&unused_args) + ); + process::exit(1); + } + Vec::new() + } + } +} + +fn render_runtime_string( + value: &str, + context: &ExecutionContext, + remaining_args: &[String], + track_usage: bool, + stats: &mut RenderStats, +) -> String { + let with_macros = apply_macros(value, &context.macros); + + let mut output = with_macros; + for template in placeholder_templates(context.placeholder.as_deref()) { + output = + replace_placeholder_template(&output, &template, remaining_args, track_usage, stats); + output = replace_array_placeholder_literal_forms( + &output, + &template, + remaining_args, + track_usage, + stats, + ); + } + + output +} + +fn apply_macros(value: &str, macros_map: &BTreeMap) -> String { + if macros_map.is_empty() { + return value.to_string(); + } + + let mut ordered_macros = macros_map + .iter() + .filter(|(key, _)| !key.is_empty()) + .collect::>(); + ordered_macros.sort_by(|(left, _), (right, _)| right.len().cmp(&left.len())); + + let mut output = value.to_string(); + for _ in 0..8 { + let mut changed = false; + for (key, replacement) in &ordered_macros { + if output.contains(*key) { + output = output.replace(*key, replacement); + changed = true; + } + } + if !changed { + break; + } + } + + output +} + +fn placeholder_templates(custom: Option<&str>) -> Vec { + let mut templates = Vec::new(); + if let Some(custom) = custom { + let custom = custom.trim(); + if !custom.is_empty() { + templates.push(custom.to_string()); + } + } + templates.push("{n}".to_string()); + templates.push("{{n}}".to_string()); + templates.push("$n".to_string()); + + let mut seen = BTreeSet::new(); + let mut unique = templates + .into_iter() + .filter(|template| seen.insert(template.clone())) + .collect::>(); + unique.sort_by(|left, right| right.len().cmp(&left.len())); + unique +} + +fn replace_placeholder_template( + input: &str, + template: &str, + remaining_args: &[String], + track_usage: bool, + stats: &mut RenderStats, +) -> String { + let Some(index_marker) = template.find('n') else { + return input.to_string(); + }; + + let prefix = &template[..index_marker]; + let suffix = &template[index_marker + 1..]; + + if prefix.is_empty() { + return input.to_string(); + } + + let mut output = String::new(); + let mut cursor = 0; + + while cursor < input.len() { + let Some(relative_prefix_start) = input[cursor..].find(prefix) else { + output.push_str(&input[cursor..]); + break; + }; + + let prefix_start = cursor + relative_prefix_start; + output.push_str(&input[cursor..prefix_start]); + + let digit_start = prefix_start + prefix.len(); + let mut digit_end = digit_start; + + while digit_end < input.len() { + let Some(ch) = input[digit_end..].chars().next() else { + break; + }; + if ch.is_ascii_digit() { + digit_end += ch.len_utf8(); + } else { + break; + } + } + + if digit_start == digit_end { + output.push_str(prefix); + cursor = prefix_start + prefix.len(); + continue; + } + + if !suffix.is_empty() { + let suffix_end = digit_end + suffix.len(); + if suffix_end > input.len() || &input[digit_end..suffix_end] != suffix { + output.push_str(prefix); + cursor = prefix_start + prefix.len(); + continue; + } + + let index_raw = &input[digit_start..digit_end]; + let index = index_raw + .parse::() + .ok() + .and_then(|value| value.checked_sub(1)); + + if track_usage { + stats.had_placeholders = true; + } + + if let Some(index) = index { + if let Some(value) = remaining_args.get(index) { + if track_usage { + stats.used_arg_indexes.insert(index); + } + output.push_str(&shell_escape(value)); + } + } + + cursor = suffix_end; + continue; + } + + let index_raw = &input[digit_start..digit_end]; + let index = index_raw + .parse::() + .ok() + .and_then(|value| value.checked_sub(1)); + + if track_usage { + stats.had_placeholders = true; + } + + if let Some(index) = index { + if let Some(value) = remaining_args.get(index) { + if track_usage { + stats.used_arg_indexes.insert(index); + } + output.push_str(&shell_escape(value)); + } + } + + cursor = digit_end; + } + + output +} + +fn replace_array_placeholder_literal_forms( + input: &str, + template: &str, + remaining_args: &[String], + track_usage: bool, + stats: &mut RenderStats, +) -> String { + let mut output = input.to_string(); + output = replace_array_literal_token( + &output, + &format!("...{template}"), + remaining_args, + track_usage, + stats, + ); + output = replace_array_literal_token( + &output, + &format!("[{template}]"), + remaining_args, + track_usage, + stats, + ); + output +} + +fn replace_array_literal_token( + input: &str, + token: &str, + remaining_args: &[String], + track_usage: bool, + stats: &mut RenderStats, +) -> String { + if token.is_empty() || !input.contains(token) { + return input.to_string(); + } + + let start_index = first_unused_arg_index(&stats.used_arg_indexes, remaining_args.len()); + let replacement = if start_index >= remaining_args.len() { + String::new() + } else { + let args = &remaining_args[start_index..]; + if track_usage { + stats.had_placeholders = true; + for index in start_index..remaining_args.len() { + stats.used_arg_indexes.insert(index); + } + } + join_shell_args(args) + }; + + input.replace(token, &replacement) +} + +fn first_unused_arg_index(used_indexes: &BTreeSet, total_args: usize) -> usize { + for index in 0..total_args { + if !used_indexes.contains(&index) { + return index; + } + } + total_args +} + fn resolve_next_dir(current: &Path, next: &str) -> PathBuf { let next_path = Path::new(next); if next_path.is_absolute() { @@ -144,31 +535,34 @@ fn ensure_working_directory(dir: &Path) { } } -fn select_runner_mode(context: &ExecutionContext) -> RunnerMode { - let check_passed = context - .check - .as_deref() - .map(|check| run_shell_command(check, &context.dir).success()) +fn select_runner_mode( + dir: &Path, + check: Option<&str>, + runner: Option<&str>, + fallback_runner: Option<&str>, +) -> RunnerMode { + let check_passed = check + .map(|command| run_shell_command(command, dir).success()) .unwrap_or(true); if check_passed { - if let Some(runner) = context.runner.clone() { - return RunnerMode::Runner(runner); + if let Some(runner) = runner { + return RunnerMode::Runner(runner.to_string()); } return RunnerMode::Direct; } - if let Some(fallback) = context.fallback_runner.clone() { - return RunnerMode::Fallback(fallback); + if let Some(fallback_runner) = fallback_runner { + return RunnerMode::Fallback(fallback_runner.to_string()); } - if context.check.is_some() { + if check.is_some() { eprintln!("[fire] Check command failed and no fallback_runner is configured."); process::exit(1); } - if let Some(runner) = context.runner.clone() { - return RunnerMode::Runner(runner); + if let Some(runner) = runner { + return RunnerMode::Runner(runner.to_string()); } RunnerMode::Direct @@ -387,40 +781,22 @@ mod tests { #[test] fn select_runner_uses_fallback_when_check_fails() { - let context = ExecutionContext { - dir: std::env::current_dir().expect("cwd"), - runner: Some("bash".to_string()), - fallback_runner: Some("sh".to_string()), - check: Some("false".to_string()), - before: None, - }; - let selected = select_runner_mode(&context); + let dir = std::env::current_dir().expect("cwd"); + let selected = select_runner_mode(&dir, Some("false"), Some("bash"), Some("sh")); assert_eq!(selected, RunnerMode::Fallback("sh".to_string())); } #[test] fn select_runner_uses_primary_when_check_passes() { - let context = ExecutionContext { - dir: std::env::current_dir().expect("cwd"), - runner: Some("bash".to_string()), - fallback_runner: Some("sh".to_string()), - check: Some("true".to_string()), - before: None, - }; - let selected = select_runner_mode(&context); + let dir = std::env::current_dir().expect("cwd"); + let selected = select_runner_mode(&dir, Some("true"), Some("bash"), Some("sh")); assert_eq!(selected, RunnerMode::Runner("bash".to_string())); } #[test] fn select_runner_returns_direct_when_no_runner() { - let context = ExecutionContext { - dir: std::env::current_dir().expect("cwd"), - runner: None, - fallback_runner: None, - check: None, - before: Some("echo prep".to_string()), - }; - let selected = select_runner_mode(&context); + let dir = std::env::current_dir().expect("cwd"); + let selected = select_runner_mode(&dir, None, None, None); assert_eq!(selected, RunnerMode::Direct); } @@ -471,4 +847,124 @@ mod tests { ))); assert!(!should_execute_before(&RunnerMode::Direct)); } + + #[test] + fn placeholders_replace_indexed_args_with_shell_escape() { + let context = ExecutionContext::default(); + let mut stats = RenderStats::default(); + let rendered = render_runtime_string( + "echo {1} {{2}} $3", + &context, + &[ + "hello".to_string(), + "sp ace".to_string(), + "quo'te".to_string(), + ], + true, + &mut stats, + ); + assert_eq!(rendered, "echo hello 'sp ace' 'quo'\"'\"'te'"); + assert!(stats.had_placeholders); + assert_eq!(stats.used_arg_indexes.len(), 3); + } + + #[test] + fn custom_placeholder_template_is_supported() { + let mut context = ExecutionContext::default(); + context.placeholder = Some("[[n]]".to_string()); + let mut stats = RenderStats::default(); + let rendered = render_runtime_string( + "echo [[1]]", + &context, + &["hey".to_string()], + true, + &mut stats, + ); + assert_eq!(rendered, "echo hey"); + } + + #[test] + fn macros_expand_before_placeholder_replacement() { + let mut context = ExecutionContext::default(); + context + .macros + .insert("{{dynamic}}".to_string(), "docker exec {{1}}".to_string()); + let mut stats = RenderStats::default(); + let rendered = render_runtime_string( + "{{dynamic}} echo hi", + &context, + &["front".to_string()], + true, + &mut stats, + ); + assert_eq!(rendered, "docker exec front echo hi"); + } + + #[test] + fn spread_placeholder_expands_to_remaining_args() { + let mut context = ExecutionContext::default(); + context.placeholder = Some("{{n}}".to_string()); + let mut stats = RenderStats::default(); + let rendered = render_runtime_string( + "echo {{1}} ...{{n}}", + &context, + &[ + "first".to_string(), + "second arg".to_string(), + "third".to_string(), + ], + true, + &mut stats, + ); + assert_eq!(rendered, "echo first 'second arg' third"); + assert_eq!(stats.used_arg_indexes.len(), 3); + } + + #[test] + fn bracket_placeholder_expands_to_remaining_args() { + let mut context = ExecutionContext::default(); + context.placeholder = Some("{{n}}".to_string()); + let mut stats = RenderStats::default(); + let rendered = render_runtime_string( + "echo [{{n}}]", + &context, + &["one".to_string(), "two".to_string()], + true, + &mut stats, + ); + assert_eq!(rendered, "echo one two"); + assert_eq!(stats.used_arg_indexes.len(), 2); + } + + #[test] + fn unresolved_args_defaults_to_passthrough_without_placeholder_or_policy() { + let context = ExecutionContext::default(); + let stats = RenderStats::default(); + let args = vec!["one".to_string(), "two".to_string()]; + assert_eq!(unresolved_args_for_tail(&context, &args, &stats), args); + } + + #[test] + fn unresolved_args_respects_ignore_policy() { + let context = ExecutionContext { + on_unused_args: Some(UnusedArgsMode::Ignore), + ..ExecutionContext::default() + }; + let stats = RenderStats::default(); + let args = vec!["one".to_string()]; + assert!(unresolved_args_for_tail(&context, &args, &stats).is_empty()); + } + + #[test] + fn unresolved_args_uses_consumed_indexes() { + let mut stats = RenderStats::default(); + stats.had_placeholders = true; + stats.used_arg_indexes.insert(0); + let context = ExecutionContext { + on_unused_args: Some(UnusedArgsMode::Ignore), + ..ExecutionContext::default() + }; + let args = vec!["one".to_string(), "two".to_string()]; + assert!(unresolved_args_for_tail(&context, &args, &stats).is_empty()); + } } From 5222786efff10d3bfdad8118ba0cd68c0e25c9cf Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Sun, 1 Mar 2026 02:10:04 -0600 Subject: [PATCH 07/10] complete command --- README.md | 25 ++-- src/cli.rs | 309 +++++++++++++++++++++++++++++++++++++++++++++- src/completion.rs | 108 ++++++++++++++-- zsh_completations | 24 ---- 4 files changed, 421 insertions(+), 45 deletions(-) delete mode 100644 zsh_completations diff --git a/README.md b/README.md index 95b8dba..c6fc624 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ Create a minimal config with an interactive wizard: fire cli init ``` +Install shell completions (standard user locations for zsh and bash): + +```bash +fire cli completion install +``` + Behavior: - Stores only the absolute directory path (no command cache, no file copy). - Avoids duplicates if the path is already installed. @@ -127,27 +133,32 @@ Expected validation error (example in VS Code): `Property cli is not allowed.` Why: `cli` is reserved for internal CLI behavior and cannot be overridden as a user command at the root `commands` level. -## Autocomplete Without External Scripts +## Autocomplete Fire supports two completion modes: - Rich zsh completion (value + description) - Bash-compatible command completion (`complete -C`) -### zsh -```zsh -source ./zsh_completations +Install both shell scripts: + +```bash +fire cli completion install ``` -### bash +Install only one shell: + ```bash -complete -o nospace -C fire fire +fire cli completion install zsh +fire cli completion install bash ``` +The installer writes completion scripts to user-level standard locations and updates `~/.zshrc` / `~/.bashrc` with managed blocks. + For `complete -C`, the shell invokes `fire` in completion mode using `COMP_LINE` / `COMP_POINT`. Completion output includes command name and `description` when present (`namedescription`) in the `__complete` protocol, which is consumed by the zsh completion script. -Note: `complete -C` only uses completion values; descriptions are a zsh-native feature through `zsh_completations`. +Note: `complete -C` only uses completion values; descriptions are shown through the installed zsh completion function. ## Execution Rules - `fire ` executes `exec` (or `run`) of the resolved command. diff --git a/src/cli.rs b/src/cli.rs index ded2e75..d4e832d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,7 +1,7 @@ use std::{ fs, io::{self, Write}, - path::PathBuf, + path::{Path, PathBuf}, process, }; @@ -11,12 +11,33 @@ pub(crate) fn handle_cli_command(command_args: &[String]) { match command_args { [cli, install] if cli == "cli" && install == "install" => run_install(), [cli, init] if cli == "cli" && init == "init" => run_init(), + [cli, completion] if cli == "cli" && completion == "completion" => { + print_completion_help(); + } + [cli, completion, install] + if cli == "cli" && completion == "completion" && install == "install" => + { + run_completion_install(CompletionTarget::All) + } + [cli, completion, install, shell] + if cli == "cli" && completion == "completion" && install == "install" => + { + let target = match parse_completion_target(shell) { + Some(target) => target, + None => { + eprintln!("[fire] Invalid shell `{shell}`. Use one of: bash, zsh, all."); + process::exit(1); + } + }; + run_completion_install(target); + } [cli] if cli == "cli" => print_cli_help(), _ => { eprintln!("[fire] Unknown cli command"); eprintln!("Usage:"); eprintln!(" fire cli install"); eprintln!(" fire cli init"); + eprintln!(" fire cli completion install [bash|zsh|all]"); process::exit(1); } } @@ -91,6 +112,13 @@ fn print_cli_help() { println!("Commands:"); println!(" install Register the current directory for global command loading"); println!(" init Create a minimal fire config file with guided prompts"); + println!(" completion Manage shell completion scripts"); +} + +fn print_completion_help() { + println!("Fire CLI Completion"); + println!("Commands:"); + println!(" install [bash|zsh|all] Install completion scripts (default: all)"); } fn prompt_base_file_name() -> String { @@ -213,9 +241,229 @@ fn non_empty(value: &str) -> Option<&str> { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CompletionTarget { + Bash, + Zsh, + All, +} + +fn parse_completion_target(value: &str) -> Option { + match value { + "bash" => Some(CompletionTarget::Bash), + "zsh" => Some(CompletionTarget::Zsh), + "all" => Some(CompletionTarget::All), + _ => None, + } +} + +fn run_completion_install(target: CompletionTarget) { + let home_dir = dirs::home_dir().unwrap_or_else(|| { + eprintln!("[fire] Could not resolve HOME directory."); + process::exit(1); + }); + + println!("Fire CLI Completion Install"); + println!("----------------------------------------"); + + let mut installed_paths: Vec = Vec::new(); + match target { + CompletionTarget::Bash => { + let path = install_bash_completion(&home_dir).unwrap_or_else(|err| { + eprintln!("[fire] {err}"); + process::exit(1); + }); + installed_paths.push(path); + } + CompletionTarget::Zsh => { + let path = install_zsh_completion(&home_dir).unwrap_or_else(|err| { + eprintln!("[fire] {err}"); + process::exit(1); + }); + installed_paths.push(path); + } + CompletionTarget::All => { + let zsh_path = install_zsh_completion(&home_dir).unwrap_or_else(|err| { + eprintln!("[fire] {err}"); + process::exit(1); + }); + installed_paths.push(zsh_path); + + let bash_path = install_bash_completion(&home_dir).unwrap_or_else(|err| { + eprintln!("[fire] {err}"); + process::exit(1); + }); + installed_paths.push(bash_path); + } + } + + println!("Installed completion files:"); + for path in &installed_paths { + println!(" - {}", path.display()); + } + println!(); + println!("Open a new shell session (or source your shell rc file) to apply changes."); +} + +fn install_zsh_completion(home_dir: &Path) -> Result { + let completion_dir = home_dir.join(".zsh").join("completions"); + fs::create_dir_all(&completion_dir) + .map_err(|err| format!("Failed to create zsh completion directory: {err}"))?; + + let completion_path = completion_dir.join("_fire"); + fs::write(&completion_path, zsh_completion_script()) + .map_err(|err| format!("Failed to write zsh completion script: {err}"))?; + + let zshrc_path = home_dir.join(".zshrc"); + upsert_managed_block_file( + &zshrc_path, + zsh_block_start_marker(), + zsh_block_end_marker(), + &zsh_completion_rc_block(), + )?; + + Ok(completion_path) +} + +fn install_bash_completion(home_dir: &Path) -> Result { + let completion_dir = home_dir + .join(".local") + .join("share") + .join("bash-completion") + .join("completions"); + fs::create_dir_all(&completion_dir) + .map_err(|err| format!("Failed to create bash completion directory: {err}"))?; + + let completion_path = completion_dir.join("fire"); + fs::write(&completion_path, bash_completion_script()) + .map_err(|err| format!("Failed to write bash completion script: {err}"))?; + + let bashrc_path = home_dir.join(".bashrc"); + upsert_managed_block_file( + &bashrc_path, + bash_block_start_marker(), + bash_block_end_marker(), + &bash_completion_rc_block(), + )?; + + Ok(completion_path) +} + +fn upsert_managed_block_file( + file_path: &Path, + start_marker: &str, + end_marker: &str, + block: &str, +) -> Result<(), String> { + let current = fs::read_to_string(file_path).unwrap_or_default(); + let updated = upsert_managed_block(¤t, start_marker, end_marker, block); + fs::write(file_path, updated) + .map_err(|err| format!("Failed to update {}: {err}", file_path.display())) +} + +fn upsert_managed_block( + current: &str, + start_marker: &str, + end_marker: &str, + block: &str, +) -> String { + if let Some(start) = current.find(start_marker) { + if let Some(end_relative) = current[start..].find(end_marker) { + let end = start + end_relative + end_marker.len(); + let mut output = String::new(); + output.push_str(¤t[..start]); + if !output.ends_with('\n') && !output.is_empty() { + output.push('\n'); + } + output.push_str(block); + output.push('\n'); + output.push_str(current[end..].trim_start_matches('\n')); + return output; + } + } + + if current.trim().is_empty() { + return format!("{block}\n"); + } + + format!("{}\n\n{block}\n", current.trim_end()) +} + +fn zsh_completion_script() -> &'static str { + r#"#compdef fire + +_fire_cli() { + local -a lines + local -a entries + local line value note + + lines=("${(@f)$(fire __complete -- "${words[@]}")}") + (( ${#lines[@]} == 0 )) && return 1 + + for line in "${lines[@]}"; do + if [[ "$line" == *$'\t'* ]]; then + value="${line%%$'\t'*}" + note="${line#*$'\t'}" + entries+=("$value:$note") + else + entries+=("$line:") + fi + done + + _describe 'fire commands' entries +} + +compdef _fire_cli fire +"# +} + +fn bash_completion_script() -> &'static str { + r#"# shellcheck shell=bash +if type complete >/dev/null 2>&1; then + complete -o nospace -C fire fire +fi +"# +} + +fn zsh_completion_rc_block() -> String { + format!( + "{}\nif [ -d \"$HOME/.zsh/completions\" ]; then\n fpath=(\"$HOME/.zsh/completions\" $fpath)\nfi\nautoload -Uz compinit\ncompinit\n{}", + zsh_block_start_marker(), + zsh_block_end_marker() + ) +} + +fn bash_completion_rc_block() -> String { + format!( + "{}\nif [ -f \"$HOME/.local/share/bash-completion/completions/fire\" ]; then\n source \"$HOME/.local/share/bash-completion/completions/fire\"\nfi\n{}", + bash_block_start_marker(), + bash_block_end_marker() + ) +} + +fn zsh_block_start_marker() -> &'static str { + "# >>> fire completion (zsh) >>>" +} + +fn zsh_block_end_marker() -> &'static str { + "# <<< fire completion (zsh) <<<" +} + +fn bash_block_start_marker() -> &'static str { + "# >>> fire completion (bash) >>>" +} + +fn bash_block_end_marker() -> &'static str { + "# <<< fire completion (bash) <<<" +} + #[cfg(test)] mod tests { - use super::{build_init_yaml, file_name_from_base, is_valid_file_base}; + use super::{ + bash_block_end_marker, bash_block_start_marker, build_init_yaml, file_name_from_base, + is_valid_file_base, parse_completion_target, upsert_managed_block, zsh_completion_script, + CompletionTarget, + }; #[test] fn empty_base_name_uses_fire_yml() { @@ -250,4 +498,61 @@ mod tests { assert!(yaml.contains("description: \"Example\"")); assert!(yaml.contains("group: \"backend\"")); } + + #[test] + fn parses_completion_target() { + assert_eq!( + parse_completion_target("bash"), + Some(CompletionTarget::Bash) + ); + assert_eq!(parse_completion_target("zsh"), Some(CompletionTarget::Zsh)); + assert_eq!(parse_completion_target("all"), Some(CompletionTarget::All)); + assert_eq!(parse_completion_target("fish"), None); + } + + #[test] + fn upsert_managed_block_appends_when_missing() { + let block = format!( + "{}\nsource ~/.bash_completion\n{}", + bash_block_start_marker(), + bash_block_end_marker() + ); + let updated = upsert_managed_block( + "export PATH=\"$PATH:/bin\"\n", + bash_block_start_marker(), + bash_block_end_marker(), + &block, + ); + assert!(updated.contains(bash_block_start_marker())); + assert!(updated.contains("source ~/.bash_completion")); + } + + #[test] + fn upsert_managed_block_replaces_existing() { + let old = format!( + "{}\nold\n{}\n", + bash_block_start_marker(), + bash_block_end_marker() + ); + let block = format!( + "{}\nnew\n{}", + bash_block_start_marker(), + bash_block_end_marker() + ); + let updated = upsert_managed_block( + &old, + bash_block_start_marker(), + bash_block_end_marker(), + &block, + ); + assert!(!updated.contains("\nold\n")); + assert!(updated.contains("\nnew\n")); + } + + #[test] + fn embedded_zsh_completion_script_contains_compdef() { + let script = zsh_completion_script(); + assert!(script.contains("#compdef fire")); + assert!(script.contains("compdef _fire_cli fire")); + } } diff --git a/src/completion.rs b/src/completion.rs index 49d37d0..aa4f78a 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -85,9 +85,8 @@ fn root_suggestions(config: &LoadedConfig, prefix: &str) -> Vec Vec { - let core_children = core_command_children(value, ""); - if !core_children.is_empty() { - return core_children; + if value == "cli" { + return core_cli_children(""); } let command_children = root_command_children(config, value); @@ -108,13 +107,12 @@ fn children_for_path( path: &[String], prefix: &str, ) -> Vec { + if let Some(core_children) = core_children_for_path(path, prefix) { + return core_children; + } + if path.len() == 1 { let head = &path[0]; - let core_children = core_command_children(head, prefix); - if !core_children.is_empty() { - return core_children; - } - let command_children = root_command_children(config, head); if !command_children.is_empty() { return filter_prefix(prefix, command_children); @@ -145,11 +143,32 @@ fn core_root_commands(prefix: &str) -> Vec { } } -fn core_command_children(command: &str, prefix: &str) -> Vec { - if command != "cli" { - return Vec::new(); +fn core_children_for_path(path: &[String], prefix: &str) -> Option> { + if path.is_empty() || path[0] != "cli" { + return None; + } + + match path.len() { + 1 => Some(core_cli_children(prefix)), + 2 => { + if path[1] == "completion" { + Some(core_cli_completion_children(prefix)) + } else { + Some(Vec::new()) + } + } + 3 => { + if path[1] == "completion" && path[2] == "install" { + Some(core_cli_completion_install_children(prefix)) + } else { + Some(Vec::new()) + } + } + _ => Some(Vec::new()), } +} +fn core_cli_children(prefix: &str) -> Vec { let suggestions = vec![ CompletionSuggestion { value: "install".to_string(), @@ -159,6 +178,44 @@ fn core_command_children(command: &str, prefix: &str) -> Vec Vec { + let suggestions = vec![CompletionSuggestion { + value: "install".to_string(), + description: Some("Install completion for bash/zsh".to_string()), + }]; + + suggestions + .into_iter() + .filter(|suggestion| suggestion.value.starts_with(prefix)) + .collect() +} + +fn core_cli_completion_install_children(prefix: &str) -> Vec { + let suggestions = vec![ + CompletionSuggestion { + value: "bash".to_string(), + description: Some("Install bash completion".to_string()), + }, + CompletionSuggestion { + value: "zsh".to_string(), + description: Some("Install zsh completion".to_string()), + }, + CompletionSuggestion { + value: "all".to_string(), + description: Some("Install both shells".to_string()), + }, ]; suggestions @@ -804,6 +861,33 @@ commands: let config = config_with_scopes(); let values = completion_suggestions(&config, &["cli".to_string()]); let names: Vec = values.into_iter().map(|it| it.value).collect(); - assert_eq!(names, vec!["install", "init"]); + assert_eq!(names, vec!["install", "init", "completion"]); + } + + #[test] + fn core_cli_completion_lists_install() { + let config = config_with_scopes(); + let values = completion_suggestions( + &config, + &["cli".to_string(), "completion".to_string(), "".to_string()], + ); + let names: Vec = values.into_iter().map(|it| it.value).collect(); + assert_eq!(names, vec!["install"]); + } + + #[test] + fn core_cli_completion_install_lists_shell_targets() { + let config = config_with_scopes(); + let values = completion_suggestions( + &config, + &[ + "cli".to_string(), + "completion".to_string(), + "install".to_string(), + "".to_string(), + ], + ); + let names: Vec = values.into_iter().map(|it| it.value).collect(); + assert_eq!(names, vec!["bash", "zsh", "all"]); } } diff --git a/zsh_completations b/zsh_completations deleted file mode 100644 index 0c13324..0000000 --- a/zsh_completations +++ /dev/null @@ -1,24 +0,0 @@ -#compdef fire - -_fire_cli() { - local -a lines - local -a entries - local line value note - - lines=("${(@f)$(fire __complete -- "${words[@]}")}") - (( ${#lines[@]} == 0 )) && return 1 - - for line in "${lines[@]}"; do - if [[ "$line" == *$'\t'* ]]; then - value="${line%%$'\t'*}" - note="${line#*$'\t'}" - entries+=("$value:$note") - else - entries+=("$line:") - fi - done - - _describe 'fire commands' entries -} - -compdef _fire_cli fire From ec44a9af2393ea3c5c67810bdd750eac605d084f Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Sun, 1 Mar 2026 02:13:57 -0600 Subject: [PATCH 08/10] release --- .github/workflows/release.yml | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6017ec0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.archive_suffix }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + target: aarch64-apple-darwin + archive_suffix: macos-arm64 + - runner: macos-14 + target: x86_64-apple-darwin + archive_suffix: macos-intel + - runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + archive_suffix: linux-x86_64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build release binary + run: cargo build --release --locked --target "${{ matrix.target }}" + + - name: Package archive + env: + VERSION: ${{ github.ref_name }} + TARGET: ${{ matrix.target }} + ARCHIVE_SUFFIX: ${{ matrix.archive_suffix }} + run: | + ARCHIVE="fire-${VERSION}-${ARCHIVE_SUFFIX}.tar.gz" + BIN_PATH="target/${TARGET}/release/fire" + + test -f "${BIN_PATH}" + tar -czf "${ARCHIVE}" -C "target/${TARGET}/release" fire + shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.archive_suffix }} + path: | + fire-${{ github.ref_name }}-${{ matrix.archive_suffix }}.tar.gz + fire-${{ github.ref_name }}-${{ matrix.archive_suffix }}.tar.gz.sha256 + + release: + name: Publish GitHub Release + runs-on: ubuntu-22.04 + needs: build + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + pattern: dist-* + path: dist + merge-multiple: true + + - name: Build checksums file + run: | + cat dist/*.sha256 | sort -k2 > dist/checksums.txt + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Fire ${{ github.ref_name }} + generate_release_notes: true + fail_on_unmatched_files: true + files: | + dist/*.tar.gz + dist/checksums.txt From 0c24f68e6fb01c0c13143a9ff2a95e82f191634b Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Sun, 1 Mar 2026 02:29:01 -0600 Subject: [PATCH 09/10] update readme --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index c6fc624..b566d4d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ A CLI with dynamic completion powered by external configuration. +## Installation + +Install with Homebrew using the `gbenm/labs` tap: + +```bash +brew tap gbenm/labs +brew install fire +``` + ## Command Configuration Fire loads YAML files from the current directory with these patterns: - `fire.yaml` From f30191de94c4262c9a9b87bfb7e1ded2bca8032e Mon Sep 17 00:00:00 2001 From: Benyamin Galeano Date: Sun, 1 Mar 2026 02:32:12 -0600 Subject: [PATCH 10/10] remove namespace id --- config.sample.fire.yml | 1 - schemas/fire.schema.json | 5 ----- 2 files changed, 6 deletions(-) diff --git a/config.sample.fire.yml b/config.sample.fire.yml index 446b593..25e04fa 100644 --- a/config.sample.fire.yml +++ b/config.sample.fire.yml @@ -4,7 +4,6 @@ dir: . # default working directory "." env_file: .env # default env file namespace: - id: 51170376-ea38-4317-ba49-4e63dfc8a571 # optional description: Example prefix: ex diff --git a/schemas/fire.schema.json b/schemas/fire.schema.json index 7cc58df..338702f 100644 --- a/schemas/fire.schema.json +++ b/schemas/fire.schema.json @@ -61,11 +61,6 @@ "type": "object", "additionalProperties": false, "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Optional namespace UUID." - }, "description": { "type": "string", "description": "Human-readable namespace label."