Skip to main content

rom_cli/
cli.rs

1//! CLI interface for ROM
2use std::{
3  io,
4  path::PathBuf,
5  process::{Command, Stdio},
6};
7
8use clap::Parser;
9use cognos::ProgressState;
10use tracing_subscriber::EnvFilter;
11
12#[derive(Debug, Parser)]
13#[command(name = "rom", version, about = "ROM - A Nix build output monitor")]
14pub struct Cli {
15  #[command(subcommand)]
16  pub command: Option<Commands>,
17
18  /// Parse JSON output from nix --log-format=internal-json
19  #[arg(long, global = true)]
20  pub json: bool,
21
22  /// Minimal output
23  #[arg(long, global = true)]
24  pub silent: bool,
25
26  /// Output format: tree, plain, dashboard
27  #[arg(long, global = true, default_value = "tree")]
28  pub format: String,
29
30  /// Legend display style: compact, table, verbose
31  #[arg(long, global = true, default_value = "table")]
32  pub legend: String,
33
34  /// Summary display style: concise, table, full
35  #[arg(long, global = true, default_value = "concise")]
36  pub summary: String,
37
38  /// Log prefix style: short, full, none
39  #[arg(long, global = true, default_value = "short")]
40  pub log_prefix: String,
41
42  /// Maximum number of log lines to display
43  #[arg(long, global = true)]
44  pub log_lines: Option<usize>,
45
46  /// Nix-family evaluator to use. Auto-detected by default
47  #[arg(long, global = true)]
48  pub platform: Option<String>,
49
50  /// Increase verbosity; controls nix log level and rom diagnostic output.
51  /// Repeatable: -v (info), -vv (debug), -vvv (trace)
52  #[arg(short = 'v', action = clap::ArgAction::Count, global = true)]
53  pub verbose: u8,
54}
55
56#[derive(Debug, clap::Subcommand)]
57pub enum Commands {
58  /// Run nix build with monitoring
59  Build {
60    /// Packages or flake expressions to build
61    packages: Vec<String>,
62
63    /// Extra flags to pass directly to nix
64    #[arg(last = true)]
65    nix_flags: Vec<String>,
66  },
67
68  /// Run nix shell with monitoring
69  Shell {
70    /// Packages or flake expressions
71    packages: Vec<String>,
72
73    /// Extra flags to pass directly to nix
74    #[arg(last = true)]
75    nix_flags: Vec<String>,
76  },
77
78  /// Run nix develop with monitoring
79  Develop {
80    /// Packages or flake expressions
81    packages: Vec<String>,
82
83    /// Extra flags to pass directly to nix
84    #[arg(last = true)]
85    nix_flags: Vec<String>,
86  },
87}
88
89struct WrapperConfig {
90  platform:         cognos::Platform,
91  silent:           bool,
92  verbose:          u8,
93  format:           rom_core::types::DisplayFormat,
94  legend_style:     rom_core::types::LegendStyle,
95  summary_style:    rom_core::types::SummaryStyle,
96  log_prefix_style: rom_core::types::LogPrefixStyle,
97  log_lines:        Option<usize>,
98}
99
100/// Run the CLI application
101pub fn run() -> eyre::Result<()> {
102  let cli = Cli::parse();
103
104  // Initialize tracing based on verbosity level; RUST_LOG overrides
105  let default_filter = match cli.verbose {
106    0 => "rom=warn",
107    1 => "rom=info",
108    2 => "rom=debug",
109    _ => "rom=trace",
110  };
111  tracing_subscriber::fmt()
112    .with_env_filter(
113      EnvFilter::try_from_default_env()
114        .unwrap_or_else(|_| EnvFilter::new(default_filter)),
115    )
116    .with_target(false)
117    .with_writer(std::io::stderr)
118    .init();
119
120  // Pre-parse typed display values before any moves of cli
121  let format = rom_core::types::DisplayFormat::from_str(&cli.format);
122  let legend_style = rom_core::types::LegendStyle::from_str(&cli.legend);
123  let summary_style = rom_core::types::SummaryStyle::from_str(&cli.summary);
124  let log_prefix_style =
125    rom_core::types::LogPrefixStyle::from_str(&cli.log_prefix);
126  let log_lines = cli.log_lines;
127  let silent = cli.silent;
128  let verbose = cli.verbose;
129  let json = cli.json;
130  let platform = cli
131    .platform
132    .as_deref()
133    .and_then(|platform| platform.parse().ok())
134    .unwrap_or_else(cognos::Platform::detect);
135
136  // Check if we're being called as a symlink (rom-build, rom-shell)
137  let program_name = std::env::args()
138    .next()
139    .and_then(|path| {
140      PathBuf::from(&path)
141        .file_name()
142        .and_then(|n| n.to_str())
143        .map(std::string::ToString::to_string)
144    })
145    .unwrap_or_else(|| "rom".to_string());
146
147  let make_config = |input_mode: rom_core::types::InputMode| {
148    rom_core::types::Config {
149      piping: false,
150      silent,
151      input_mode,
152      show_timers: true,
153      width: None,
154      format,
155      legend_style,
156      summary_style,
157      log_prefix_style,
158      log_line_limit: log_lines,
159    }
160  };
161
162  let cfg = WrapperConfig {
163    platform,
164    silent,
165    verbose,
166    format,
167    legend_style,
168    summary_style,
169    log_prefix_style,
170    log_lines,
171  };
172
173  match (&program_name[..], cli.command) {
174    // rom-build symlink
175    ("rom-build", _) => {
176      let args: Vec<String> = std::env::args().skip(1).collect();
177      let (packages, nix_flags) = parse_args_with_separator(&args);
178      run_build_wrapper(packages, nix_flags, &cfg)?;
179      Ok(())
180    },
181
182    // rom-shell symlink
183    ("rom-shell", _) => {
184      let args: Vec<String> = std::env::args().skip(1).collect();
185      let (packages, nix_flags) = parse_args_with_separator(&args);
186      run_shell_wrapper(packages, nix_flags, &cfg)?;
187      Ok(())
188    },
189
190    // rom build command
191    (
192      _,
193      Some(Commands::Build {
194        packages,
195        nix_flags,
196      }),
197    ) => {
198      if packages.is_empty() && json {
199        let stdin = io::stdin();
200        let stdout = io::stdout();
201        return Ok(rom_core::monitor_stream(
202          make_config(rom_core::types::InputMode::Json),
203          stdin.lock(),
204          stdout.lock(),
205        )?);
206      }
207      if packages.is_empty() {
208        eyre::bail!(
209          "No package or flake specified for build\nUsage: rom build \
210           <package> [-- <flags>]\nExample: rom build nixpkgs#hello -- \
211           --rebuild"
212        );
213      }
214      run_build_wrapper(packages, nix_flags, &cfg)?;
215      Ok(())
216    },
217
218    // rom shell command
219    (
220      _,
221      Some(Commands::Shell {
222        packages,
223        nix_flags,
224      }),
225    ) => {
226      if packages.is_empty() && json {
227        let stdin = io::stdin();
228        let stdout = io::stdout();
229        return Ok(rom_core::monitor_stream(
230          make_config(rom_core::types::InputMode::Json),
231          stdin.lock(),
232          stdout.lock(),
233        )?);
234      }
235      if packages.is_empty() {
236        eyre::bail!(
237          "No package or flake specified for shell\nUsage: rom shell \
238           <package> [-- <flags>]\nExample: rom shell nixpkgs#python3 -- \
239           --pure"
240        );
241      }
242      run_shell_wrapper(packages, nix_flags, &cfg)?;
243      Ok(())
244    },
245
246    // rom develop command
247    (
248      _,
249      Some(Commands::Develop {
250        packages,
251        nix_flags,
252      }),
253    ) => {
254      if packages.is_empty() && json {
255        let stdin = io::stdin();
256        let stdout = io::stdout();
257        return Ok(rom_core::monitor_stream(
258          make_config(rom_core::types::InputMode::Json),
259          stdin.lock(),
260          stdout.lock(),
261        )?);
262      }
263      if packages.is_empty() {
264        eyre::bail!(
265          "No package or flake specified for develop\nUsage: rom develop \
266           <package> [-- <flags>]\nExample: rom develop nixpkgs#hello -- \
267           --impure"
268        );
269      }
270      run_develop_wrapper(packages, nix_flags, &cfg)?;
271      Ok(())
272    },
273
274    // Direct piping mode, read from stdin
275    (_, None) => {
276      let input_mode = if json {
277        rom_core::types::InputMode::Json
278      } else {
279        rom_core::types::InputMode::Human
280      };
281      let stdin = io::stdin();
282      let stdout = io::stdout();
283      Ok(rom_core::monitor_stream(
284        make_config(input_mode),
285        stdin.lock(),
286        stdout.lock(),
287      )?)
288    },
289  }
290}
291
292/// Parse arguments, separating those before and after `--`
293/// Returns (`args_before_separator`, `args_after_separator`)
294///
295/// Everything before `--` is for the package name and rom arguments.
296/// Everything after `--` goes directly to nix.
297#[must_use]
298pub fn parse_args_with_separator(
299  args: &[String],
300) -> (Vec<String>, Vec<String>) {
301  if let Some(pos) = args.iter().position(|arg| arg == "--") {
302    // Arguments before -- are package/rom args
303    let before = args[..pos].to_vec();
304
305    // Arguments after -- go to nix
306    let after = args[pos + 1..].to_vec();
307    (before, after)
308  } else {
309    // No separator found - all args are package/rom args for backward
310    // compatibility
311    (args.to_vec(), Vec::new())
312  }
313}
314
315/// Returns the nix verbosity flag for the given level.
316/// Always produces at least `-v` so build events are emitted via
317/// `--log-format internal-json`.
318fn nix_verbosity_flag(verbose: u8) -> String {
319  format!("-{}", "v".repeat(verbose.max(1) as usize))
320}
321
322fn run_build_wrapper(
323  packages: Vec<String>,
324  nix_flags: Vec<String>,
325  cfg: &WrapperConfig,
326) -> eyre::Result<()> {
327  if packages.is_empty() {
328    eyre::bail!(
329      "No package or flake specified for build\nUsage: rom build <package> \
330       [-- <flags>]\nExample: rom build nixpkgs#hello -- --rebuild"
331    );
332  }
333
334  let mut cmd_args = vec![
335    "build".to_string(),
336    nix_verbosity_flag(cfg.verbose),
337    "--log-format".to_string(),
338    "internal-json".to_string(),
339  ];
340  cmd_args.extend(packages);
341  cmd_args.extend(nix_flags);
342
343  let exit_code = run_monitored_command(cfg.platform.binary(), cmd_args, cfg)?;
344  if exit_code != 0 {
345    std::process::exit(exit_code);
346  }
347  Ok(())
348}
349
350fn run_shell_wrapper(
351  packages: Vec<String>,
352  nix_flags: Vec<String>,
353  cfg: &WrapperConfig,
354) -> eyre::Result<()> {
355  if packages.is_empty() {
356    eyre::bail!(
357      "No package or flake specified for shell\nUsage: rom shell <package> \
358       [-- <flags>]\nExample: rom shell nixpkgs#python3 -- --pure"
359    );
360  }
361
362  // First pass: monitor the build phase with --command exit
363  let mut monitor_args = vec![
364    "shell".to_string(),
365    nix_verbosity_flag(cfg.verbose),
366    "--log-format".to_string(),
367    "internal-json".to_string(),
368  ];
369  let shell_args: Vec<String> =
370    packages.iter().chain(nix_flags.iter()).cloned().collect();
371  monitor_args.extend(replace_command_with_exit(&shell_args));
372
373  let exit_code =
374    run_monitored_command(cfg.platform.binary(), monitor_args, cfg)?;
375
376  if exit_code != 0 {
377    std::process::exit(exit_code);
378  }
379
380  // Second pass: enter the actual shell
381  if !cfg.silent {
382    let mut shell_args = vec!["shell".to_string()];
383    shell_args.extend(packages);
384    shell_args.extend(nix_flags);
385
386    let status = Command::new(cfg.platform.binary())
387      .args(&shell_args)
388      .status()
389      .map_err(rom_core::error::RomError::Io)?;
390
391    std::process::exit(status.code().unwrap_or(1));
392  }
393
394  Ok(())
395}
396
397fn run_develop_wrapper(
398  packages: Vec<String>,
399  nix_flags: Vec<String>,
400  cfg: &WrapperConfig,
401) -> eyre::Result<()> {
402  // First pass: monitor with --command true
403  let mut monitor_args = vec![
404    "develop".to_string(),
405    nix_verbosity_flag(cfg.verbose),
406    "--log-format".to_string(),
407    "internal-json".to_string(),
408    "--command".to_string(),
409    "true".to_string(),
410  ];
411  monitor_args.extend(packages.clone());
412  monitor_args.extend(nix_flags.clone());
413
414  let exit_code =
415    run_monitored_command(cfg.platform.binary(), monitor_args, cfg)?;
416
417  if exit_code != 0 {
418    std::process::exit(exit_code);
419  }
420
421  // Second pass: enter the actual dev shell
422  if !cfg.silent {
423    let mut develop_args = vec!["develop".to_string()];
424    develop_args.extend(packages);
425    develop_args.extend(nix_flags);
426
427    let status = Command::new(cfg.platform.binary())
428      .args(&develop_args)
429      .status()
430      .map_err(rom_core::error::RomError::Io)?;
431
432    std::process::exit(status.code().unwrap_or(1));
433  }
434
435  Ok(())
436}
437
438fn run_monitored_command(
439  command: &str,
440  args: Vec<String>,
441  cfg: &WrapperConfig,
442) -> eyre::Result<i32> {
443  let silent = cfg.silent;
444  let format = cfg.format;
445  let legend_style = cfg.legend_style;
446  let summary_style = cfg.summary_style;
447  let log_prefix_style = cfg.log_prefix_style;
448  let log_line_limit = cfg.log_lines;
449  use std::{
450    io::{BufRead, BufReader},
451    sync::{Arc, Mutex},
452    thread,
453    time::Duration,
454  };
455
456  let mut child = Command::new(command)
457    .args(&args)
458    .stdout(Stdio::piped())
459    .stderr(Stdio::piped())
460    .spawn()
461    .map_err(rom_core::error::RomError::Io)?;
462
463  let stderr = child.stderr.take().expect("Failed to capture stderr");
464  let stdout = child.stdout.take().expect("Failed to capture stdout");
465
466  // Create shared state
467  let state = Arc::new(Mutex::new(rom_core::state::State::new()));
468  let state_clone = state.clone();
469  let render_state = state;
470
471  // Track whether we're done processing
472  let processing_done = Arc::new(Mutex::new(false));
473  let processing_done_clone = processing_done.clone();
474
475  // Track start time for initial timer
476  let start_time = Arc::new(Mutex::new(rom_core::state::current_time()));
477  let start_time_clone = start_time;
478
479  // Buffer for build logs - collected and passed to Display for coordinated
480  // rendering
481  let log_buffer =
482    Arc::new(Mutex::new(std::collections::VecDeque::<String>::new()));
483  let log_buffer_clone = log_buffer.clone();
484  let log_buffer_render = log_buffer;
485
486  // Spawn thread to read and parse stderr (where nix outputs logs)
487  let stderr_thread = thread::spawn(move || {
488    use tracing::debug;
489    let reader = BufReader::new(stderr);
490    let mut json_count = 0;
491    let mut non_json_count = 0;
492
493    for line in reader.lines().map_while(Result::ok) {
494      // Try to parse as JSON message
495      if let Some(json_line) = line.strip_prefix("@nix ") {
496        json_count += 1;
497        if let Ok(action) = serde_json::from_str::<cognos::Actions>(json_line) {
498          debug!("Parsed JSON message #{}: {:?}", json_count, action);
499
500          // Process the action first to update state
501          let mut state = state_clone.lock().unwrap();
502          let derivation_count_before = state.derivation_infos.len();
503          rom_core::update::process_message(&mut state, action.clone());
504          rom_core::update::maintain_state(
505            &mut state,
506            rom_core::state::current_time(),
507          );
508          let derivation_count_after = state.derivation_infos.len();
509
510          // Now handle build log messages after state is updated
511          // Buffer them for coordinated rendering with the display
512          match &action {
513            cognos::Actions::Message { msg, raw_msg, .. } => {
514              // Prefer raw_msg (Lix): message without ANSI escapes.
515              let display = raw_msg.as_deref().unwrap_or(msg.as_str());
516              let mut logs = log_buffer_clone.lock().unwrap();
517              logs.push_back(display.to_string());
518              if let Some(limit) = log_line_limit {
519                while logs.len() > limit {
520                  logs.pop_front();
521                }
522              }
523            },
524            cognos::Actions::Result {
525              fields,
526              result_type,
527              id,
528            } => {
529              // BuildLogLine (101) carries a single log line from the builder
530              if matches!(result_type, cognos::ResultType::BuildLogLine)
531                && !fields.is_empty()
532                && let Some(log_text) = fields[0].as_str()
533              {
534                let use_color = !silent;
535                let prefix = state
536                  .get_activity_prefix(*id, &log_prefix_style, use_color)
537                  .unwrap_or_default();
538                let prefixed_log = format!("{prefix}{log_text}");
539                let mut logs = log_buffer_clone.lock().unwrap();
540                logs.push_back(prefixed_log);
541                if let Some(limit) = log_line_limit {
542                  while logs.len() > limit {
543                    logs.pop_front();
544                  }
545                }
546              }
547            },
548            _ => {},
549          }
550
551          if derivation_count_after != derivation_count_before {
552            debug!(
553              "Derivation count changed: {} -> {}",
554              derivation_count_before, derivation_count_after
555            );
556          }
557        } else {
558          debug!("Failed to parse JSON: {}", json_line);
559        }
560      } else {
561        // Non-JSON lines, buffer them
562        non_json_count += 1;
563        let mut logs = log_buffer_clone.lock().unwrap();
564        logs.push_back(line.clone());
565        // Keep only recent logs based on limit
566        if let Some(limit) = log_line_limit {
567          while logs.len() > limit {
568            logs.pop_front();
569          }
570        }
571      }
572    }
573    debug!(
574      "Stderr thread finished: {} JSON messages, {} non-JSON lines",
575      json_count, non_json_count
576    );
577    *processing_done_clone.lock().unwrap() = true;
578  });
579
580  // Read stdout (final nix output)
581  let stdout_lines = Arc::new(Mutex::new(Vec::new()));
582  let stdout_lines_clone = stdout_lines.clone();
583
584  let stdout_thread = thread::spawn(move || {
585    let reader = BufReader::new(stdout);
586    for line in reader.lines().map_while(Result::ok) {
587      stdout_lines_clone.lock().unwrap().push(line);
588    }
589  });
590
591  // Render loop, this is what displays the build graph
592  let render_thread = thread::spawn(move || {
593    use rom_core::display::{Display, DisplayConfig};
594
595    let display_config = DisplayConfig {
596      show_timers: true,
597      max_tree_depth: 10,
598      max_visible_lines: 100,
599      use_color: true,
600      format,
601      legend_style,
602      summary_style,
603      icons: rom_core::icons::detect(),
604    };
605
606    let mut display = Display::new(io::stderr(), display_config).unwrap();
607    let mut last_timer_display: Option<String> = None;
608
609    // Render loop
610    loop {
611      thread::sleep(Duration::from_millis(100));
612      let done = *processing_done.lock().unwrap();
613
614      let state = render_state.lock().unwrap();
615      let has_activity = !state.derivation_infos.is_empty()
616        || !state.full_summary.running_builds.is_empty()
617        || !state.full_summary.planned_builds.is_empty();
618
619      {
620        // Get buffered logs for coordinated rendering (suppressed when
621        // --silent)
622        let logs: Vec<String> = if silent {
623          vec![]
624        } else {
625          log_buffer_render.lock().unwrap().iter().cloned().collect()
626        };
627
628        if has_activity || state.progress_state != ProgressState::JustStarted {
629          // Clear any previous timer display
630          if last_timer_display.is_some() {
631            last_timer_display = None;
632          }
633          let _ = display.render(&state, &logs);
634        } else {
635          // Show initial timer while waiting for activity
636          let start = *start_time_clone.lock().unwrap();
637          let elapsed = rom_core::state::current_time() - start;
638          let timer_text =
639            format!("⏱ {}", rom_core::display::format_duration(elapsed));
640
641          // Only update if changed (to avoid flicker)
642          if last_timer_display.as_ref() != Some(&timer_text) {
643            let _ = display.render(&state, &logs);
644            last_timer_display = Some(timer_text);
645          }
646        }
647      }
648
649      if done {
650        break;
651      }
652    }
653
654    // Final render
655    thread::sleep(Duration::from_millis(50));
656    {
657      let mut state = render_state.lock().unwrap();
658      rom_core::update::finish_state(&mut state);
659      let _ = display.render_final(&state);
660    }
661  });
662
663  // Wait for process to complete
664  let status = child.wait().map_err(rom_core::error::RomError::Io)?;
665
666  // Wait for threads to finish
667  let _ = stderr_thread.join();
668  let _ = stdout_thread.join();
669  let _ = render_thread.join();
670
671  // Print captured stdout (nix's final output)
672  let stdout_lines = stdout_lines.lock().unwrap();
673  for line in stdout_lines.iter() {
674    use std::io::Write;
675    let _ = writeln!(std::io::stdout(), "{line}");
676  }
677
678  Ok(status.code().unwrap_or(1))
679}
680
681/// Replace --command/-c arguments with "sh -c exit" for monitoring pass
682pub fn replace_command_with_exit(args: &[String]) -> Vec<String> {
683  let mut result = Vec::new();
684  let mut skip_next = false;
685
686  for arg in args {
687    if skip_next {
688      skip_next = false;
689      continue;
690    }
691
692    if arg == "--command" || arg == "-c" {
693      // Skip this and the next argument
694      skip_next = true;
695      continue;
696    }
697
698    result.push(arg.clone());
699  }
700
701  // Add our exit command
702  result.push("--command".to_string());
703  result.push("sh".to_string());
704  result.push("-c".to_string());
705  result.push("exit".to_string());
706
707  result
708}