1mod args;
2mod batch;
3mod command;
4mod command_form;
5mod config;
6mod eval;
7mod eval_config;
8mod execute;
9mod input;
10mod run_once;
11mod types;
12mod worktree;
13
14use anyhow::Context;
15use args::{Cli, CliCommand, EvalCommand};
16use clap::Parser;
17use config::CliDefaults;
18use moonlight_core::{
19 config::load_optional_config,
20 report::{render_report, ReportFormat},
21 review::{ReviewStatus, ReviewStore, ReviewUpdate},
22 storage::JsonlStorageReader,
23 Adapter, Classification, RunFilter,
24};
25use std::process::ExitCode;
26
27#[derive(Debug)]
28pub struct ExitCodeError {
29 code: u8,
30 message: String,
31}
32
33impl ExitCodeError {
34 fn new(code: u8, error: anyhow::Error) -> Self {
35 Self {
36 code,
37 message: format!("{error:#}"),
38 }
39 }
40
41 pub fn code(&self) -> u8 {
42 self.code
43 }
44}
45
46impl std::fmt::Display for ExitCodeError {
47 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 self.message.fmt(formatter)
49 }
50}
51
52impl std::error::Error for ExitCodeError {}
53
54pub async fn run_cli() -> anyhow::Result<ExitCode> {
55 let cli = Cli::parse();
56 let file_config = load_optional_config(cli.config.config.as_deref(), cli.config.no_config)?;
57 let defaults = CliDefaults::from_config(&file_config);
58
59 match cli.command {
60 CliCommand::Run(args) => {
61 run_once::run(args, &defaults).await?;
62 Ok(ExitCode::SUCCESS)
63 }
64 CliCommand::Batch(args) => {
65 batch::batch(args, &defaults).await?;
66 Ok(ExitCode::SUCCESS)
67 }
68 CliCommand::List(args) => {
69 let storage = JsonlStorageReader::new(storage_path(args.output.storage, &defaults));
70 let page = storage
71 .filtered_page(
72 &run_filter(args.classification, args.adapter, args.query, args.status)?,
73 args.limit.unwrap_or(100),
74 args.offset,
75 )
76 .await?;
77 print_json(&page, args.output.compact)?;
78 Ok(ExitCode::SUCCESS)
79 }
80 CliCommand::Stats(args) => {
81 let storage = JsonlStorageReader::new(storage_path(args.storage, &defaults));
82 print_json(&storage.stats().await?, args.compact)?;
83 Ok(ExitCode::SUCCESS)
84 }
85 CliCommand::Show(args) => {
86 let storage = JsonlStorageReader::new(storage_path(args.output.storage, &defaults));
87 let run = storage
88 .get(args.id)
89 .await?
90 .with_context(|| format!("comparison run {} was not found", args.id))?;
91 print_json(&run, args.output.compact)?;
92 Ok(ExitCode::SUCCESS)
93 }
94 CliCommand::Report(args) => {
95 let storage = JsonlStorageReader::new(storage_path(args.storage, &defaults));
96 let run = storage
97 .get(args.id)
98 .await?
99 .with_context(|| format!("comparison run {} was not found", args.id))?;
100 let format = args.format.parse::<ReportFormat>()?;
101 println!("{}", render_report(&run, None, format)?);
102 Ok(ExitCode::SUCCESS)
103 }
104 CliCommand::Review(args) => {
105 let store = ReviewStore::load(defaults.review_state_path.clone()).await?;
106 let state = store
107 .put(
108 args.id,
109 ReviewUpdate {
110 status: args.status.parse::<ReviewStatus>()?,
111 note: args.note,
112 tags: Some(args.tags),
113 },
114 )
115 .await?;
116 print_json(&state, false)?;
117 Ok(ExitCode::SUCCESS)
118 }
119 CliCommand::Reviews(args) => {
120 let store = ReviewStore::load(defaults.review_state_path.clone()).await?;
121 let status = args
122 .status
123 .map(|status| status.parse::<ReviewStatus>())
124 .transpose()?;
125 print_json(&store.list(status).await, args.compact)?;
126 Ok(ExitCode::SUCCESS)
127 }
128 CliCommand::Eval(command) => match command {
129 EvalCommand::Run(args) => eval::run(args, &defaults)
130 .await
131 .map_err(|error| ExitCodeError::new(2, error).into()),
132 EvalCommand::Report(args) => eval::report(args, &defaults)
133 .await
134 .map_err(|error| ExitCodeError::new(2, error).into()),
135 },
136 }
137}
138
139fn storage_path(args: args::StorageArgs, defaults: &CliDefaults) -> std::path::PathBuf {
140 args.storage_path
141 .unwrap_or_else(|| defaults.storage_path.clone())
142}
143
144fn print_json(value: &impl serde::Serialize, compact: bool) -> anyhow::Result<()> {
145 if compact {
146 println!("{}", serde_json::to_string(value)?);
147 } else {
148 println!("{}", serde_json::to_string_pretty(value)?);
149 }
150 Ok(())
151}
152
153fn run_filter(
154 classification: Option<String>,
155 adapter: Option<String>,
156 query: Option<String>,
157 status: Option<u16>,
158) -> anyhow::Result<RunFilter> {
159 Ok(RunFilter {
160 classification: classification
161 .map(|value| value.parse::<Classification>())
162 .transpose()?,
163 adapter: adapter.map(|value| value.parse::<Adapter>()).transpose()?,
164 query,
165 status,
166 has_noise: None,
167 has_diff: None,
168 })
169}