1pub mod args;
8mod ask;
9mod commands;
10mod template;
11mod tools;
12
13use std::io::{BufRead, IsTerminal, Write};
14use std::path::PathBuf;
15
16use balise_core::error_codes::{CLI0001, CLI0003, ErrorCode};
17use balise_core::json::{self, Value};
18use balise_core::text::closest;
19
20use args::{GLOBAL, Opt, Parsed};
21pub use tools::{Machine, System};
22
23pub struct Command {
25 pub name: &'static str,
26 pub summary: &'static str,
27 pub arguments: &'static str,
29 pub options: &'static [Opt],
30 pub exclusive: &'static [&'static [&'static str]],
32 pub run: fn(&Parsed, &mut Context) -> Result<Report, Failure>,
33}
34
35pub const COMMANDS: &[&Command] = &[
37 &commands::new::COMMAND,
38 &commands::explain::COMMAND,
39 &commands::policy::COMMAND,
40];
41
42pub struct Context<'a> {
44 pub cwd: PathBuf,
45 pub interactive: bool,
47 pub input: &'a mut dyn BufRead,
48 pub output: &'a mut dyn Write,
50 pub machine: &'a dyn Machine,
51}
52
53pub struct Report {
55 pub text: String,
56 pub data: Value,
57}
58
59#[derive(Debug)]
62pub struct Failure {
63 pub code: &'static ErrorCode,
64 pub message: String,
65 pub fix: String,
66 pub exit: u8,
67}
68
69impl Failure {
70 pub fn usage(code: &'static ErrorCode, args: &[(&str, &str)], fix: &[(&str, &str)]) -> Failure {
72 Failure {
73 code,
74 message: code.format_message(args),
75 fix: code.format_fix(fix),
76 exit: 2,
77 }
78 }
79
80 pub fn problem(
82 code: &'static ErrorCode,
83 args: &[(&str, &str)],
84 fix: &[(&str, &str)],
85 ) -> Failure {
86 Failure {
87 exit: 1,
88 ..Failure::usage(code, args, fix)
89 }
90 }
91
92 pub fn report(&self) -> String {
94 format!(
95 "{}: {}\n fix: {}\n more: balise explain {}",
96 self.code.code(),
97 self.message,
98 self.fix,
99 self.code.code()
100 )
101 }
102
103 fn json(&self) -> Value {
104 Value::Object(vec![(
105 "error".into(),
106 Value::Object(vec![
107 ("code".into(), Value::String(self.code.code().into())),
108 ("message".into(), Value::String(self.message.clone())),
109 ("fix".into(), Value::String(self.fix.clone())),
110 ]),
111 )])
112 }
113}
114
115pub fn person_at_terminal() -> bool {
118 const AGENTS: &[&str] = &["CLAUDECODE", "GEMINI_CLI", "CI"];
119 std::io::stdin().is_terminal()
120 && std::io::stdout().is_terminal()
121 && AGENTS.iter().all(|name| std::env::var_os(name).is_none())
122}
123
124pub fn run(args: &[String], context: &mut Context) -> (String, u8) {
127 let json = args.iter().any(|arg| arg == "--json");
128 let result = dispatch(args, context);
129 match (result, json) {
130 (Ok(report), false) => (report.text, 0),
131 (Ok(report), true) => (to_json(&report.data), 0),
132 (Err(failure), false) => (failure.report(), failure.exit),
133 (Err(failure), true) => (to_json(&failure.json()), failure.exit),
134 }
135}
136
137fn dispatch(args: &[String], context: &mut Context) -> Result<Report, Failure> {
138 let (name, rest) = match args.split_first() {
139 None => return Ok(help(None)),
140 Some((name, rest)) => (name.as_str(), rest),
141 };
142 match name {
143 "help" | "--help" | "-h" => return Ok(help(rest.first().map(String::as_str))),
144 "--version" | "-V" => {
145 return Ok(text(format!("balise {}", env!("CARGO_PKG_VERSION"))));
146 }
147 _ => {}
148 }
149 let Some(command) = COMMANDS.iter().find(|command| command.name == name) else {
150 let names: Vec<&str> = COMMANDS.iter().map(|command| command.name).collect();
151 let suggestion = closest(name, &names)
152 .map(|name| format!("; did you mean `{name}`?"))
153 .unwrap_or_default();
154 return Err(Failure::usage(
155 CLI0001,
156 &[("command", name), ("suggestion", &suggestion)],
157 &[],
158 ));
159 };
160 let parsed = args::parse(command.name, rest, command.options, command.exclusive)?;
161 if parsed.has("--help") {
162 return Ok(help(Some(command.name)));
163 }
164 if parsed.has("--no-input") {
165 context.interactive = false;
166 }
167 (command.run)(&parsed, context)
168}
169
170pub(crate) fn text(text: String) -> Report {
172 Report {
173 data: Value::String(text.clone()),
174 text,
175 }
176}
177
178fn help(command: Option<&str>) -> Report {
180 let mut out = String::new();
181 match command.and_then(|name| COMMANDS.iter().find(|c| c.name == name)) {
182 Some(command) => {
183 out.push_str(&format!(
184 "balise {} {}\n\n{}\n",
185 command.name, command.arguments, command.summary
186 ));
187 out.push_str("\nOptions:\n");
188 for opt in command.options.iter().chain(GLOBAL) {
189 let mut line = format!(" {:<26} {}", args::usage(opt), opt.help);
190 if !opt.values.is_empty() {
191 line.push_str(&format!(" ({})", opt.values.join(", ")));
192 }
193 out.push_str(&line);
194 out.push('\n');
195 }
196 }
197 None => {
198 out.push_str(&format!(
199 "balise {}: the Balise command-line tool\n\nCommands:\n",
200 env!("CARGO_PKG_VERSION")
201 ));
202 for command in COMMANDS {
203 out.push_str(&format!(" {:<10} {}\n", command.name, command.summary));
204 }
205 out.push_str("\nRun `balise help <command>` for its options.\n");
206 }
207 }
208 text(out.trim_end().to_string())
209}
210
211pub(crate) fn missing(command: &str, missing: &str, example: &str) -> Failure {
213 Failure::usage(
214 CLI0003,
215 &[("command", command), ("missing", missing)],
216 &[("example", example)],
217 )
218}
219
220fn to_json(value: &Value) -> String {
221 let mut out = String::new();
222 json::write_value(&mut out, value);
223 out
224}