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