1 #![forbid(unsafe_code)]2 //! f0rge CLI spine (PLAN M2): dispatch, flags, output, errors.3 //! Feature agents: replace one stub body + one dispatch line. Nothing else.4 5 mod args;6 mod cmd;7 mod output;8 9 use std::fmt;10 use std::path::PathBuf;11 12 // ---- errors: one line on stderr, no paths beyond basename (SEC-10) ----13 14 #[derive(Debug, Clone, PartialEq, Eq)]15 pub enum CliError {16 Usage(String),17 Runtime(String),18 Io(String),19 }20 21 impl fmt::Display for CliError {22 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {23 match self {24 Self::Usage(m) => write!(f, "usage: {m}"),25 Self::Runtime(m) => write!(f, "error: {m}"),26 Self::Io(m) => write!(f, "error: {m}"),27 }28 }29 }30 31 impl CliError {32 pub fn code(&self) -> i32 {33 match self {34 Self::Usage(_) => 2,35 Self::Runtime(_) | Self::Io(_) => 1,36 }37 }38 }39 40 impl From<f0rge_core::ForgeError> for CliError {41 fn from(e: f0rge_core::ForgeError) -> Self {42 Self::Runtime(e.to_string())43 }44 }45 46 fn main() {47 std::process::exit(real_main());48 }49 50 fn real_main() -> i32 {51 let argv: Vec<String> = std::env::args().skip(1).collect();52 match run(&argv) {53 Ok(()) => 0,54 Err(e) => {55 eprintln!("{e}");56 e.code()57 }58 }59 }60 61 fn run(argv: &[String]) -> Result<(), CliError> {62 let g = args::parse_global(argv)?;63 // --version wins over everything.64 if g.version {65 output::out(concat!("f0rge ", env!("CARGO_PKG_VERSION"), "\n"));66 return Ok(());67 }68 // --skill wins over everything.69 if g.skill {70 let out = crate::cmd::skill::SKILL_TEXT;71 output::emit_text(out, g.jq.as_deref());72 return Ok(());73 }74 let Some(sub) = g.sub.clone() else {75 // no args, or only globals (-h/--help): root help to stdout (EPIPE-safe).76 output::out(crate::cmd::ROOT_HELP);77 return Ok(());78 };79 // resolve forge root: cwd (init handles its own dir).80 let cwd: PathBuf = std::env::current_dir()81 .map_err(|_| CliError::Io("cwd: io error".to_string()))?;82 let fg = f0rge_core::Forge::open(&cwd);83 crate::cmd::dispatch(&sub, &g.rest, &fg, &g)84 }85 86 #[cfg(test)]87 mod budget_tests {88 fn lines_and_bytes(s: &str) -> (usize, usize) {89 (s.lines().count(), s.len())90 }91 92 #[test]93 fn root_help_within_budget() {94 let (lines, bytes) = lines_and_bytes(crate::cmd::ROOT_HELP);95 assert!(lines <= 12, "root help {lines} lines > 12");96 assert!(bytes <= 300, "root help {bytes} bytes > 300");97 }98 99 #[test]100 fn skill_within_budget() {101 let t = crate::cmd::skill::SKILL_TEXT;102 let (lines, bytes) = lines_and_bytes(t);103 assert!(lines <= 30, "skill {lines} lines > 30");104 assert!(bytes <= 1000, "skill {bytes} bytes > 1000");105 }106 107 #[test]108 fn all_sub_helps_within_budget() {109 let helps = crate::cmd::all_helps();110 assert_eq!(helps.len(), 12, "expected 12 sub helps");111 for (name, h) in helps {112 let (lines, bytes) = lines_and_bytes(h);113 assert!(lines <= 8, "{name} -h {lines} lines > 8");114 assert!(bytes <= 250, "{name} -h {bytes} bytes > 250");115 }116 }117 }