Skip to main content

tailtriage_cli/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4//! `tailtriage-cli` is the command-line artifact loader and report emitter.
5//! For in-process Rust analysis/report APIs, use `tailtriage-analyzer`.
6
7use std::fmt::Write as _;
8use std::path::Path;
9
10use tailtriage_analyzer::{analyze_option_descriptors, AnalyzeConfigError, AnalyzeOptions};
11
12/// Artifact loading and validation helpers for CLI workflows.
13pub mod artifact;
14
15/// CLI-local error for building analyzer options from config and overrides.
16#[derive(Debug)]
17pub enum CliAnalyzeConfigError {
18    /// Failed to read analyzer config file from disk.
19    ReadConfig {
20        /// Path passed by the user.
21        path: std::path::PathBuf,
22        /// Underlying filesystem I/O error.
23        source: std::io::Error,
24    },
25    /// Analyzer TOML/override/validation error.
26    Analyzer(AnalyzeConfigError),
27}
28
29impl std::fmt::Display for CliAnalyzeConfigError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Self::ReadConfig { path, source } => {
33                write!(
34                    f,
35                    "failed to read analyzer config '{}': {source}",
36                    path.display()
37                )
38            }
39            Self::Analyzer(inner) => inner.fmt(f),
40        }
41    }
42}
43
44impl std::error::Error for CliAnalyzeConfigError {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        match self {
47            Self::ReadConfig { source, .. } => Some(source),
48            Self::Analyzer(inner) => Some(inner),
49        }
50    }
51}
52
53impl From<AnalyzeConfigError> for CliAnalyzeConfigError {
54    fn from(value: AnalyzeConfigError) -> Self {
55        Self::Analyzer(value)
56    }
57}
58
59/// Builds analyzer options from defaults, optional TOML config path, and ordered CLI overrides.
60///
61/// Precedence order is:
62/// built-in defaults < analyzer TOML < ordered `overrides`.
63///
64/// # Errors
65/// Returns [`CliAnalyzeConfigError::ReadConfig`] if the config file cannot be read.
66/// Returns [`CliAnalyzeConfigError::Analyzer`] for TOML parse/schema/semantic errors or override
67/// errors.
68pub fn build_analyze_options(
69    analyzer_config: Option<&Path>,
70    overrides: &[String],
71) -> Result<AnalyzeOptions, CliAnalyzeConfigError> {
72    let mut options = AnalyzeOptions::default();
73
74    if let Some(path) = analyzer_config {
75        let input =
76            std::fs::read_to_string(path).map_err(|source| CliAnalyzeConfigError::ReadConfig {
77                path: path.to_path_buf(),
78                source,
79            })?;
80        options = options.merge_toml_str(&input)?;
81    }
82
83    options.apply_overrides(overrides.iter().map(String::as_str))?;
84    Ok(options)
85}
86
87/// Returns deterministic help text for analyzer option paths exposed by `tailtriage-analyzer`.
88#[must_use]
89pub fn analyzer_options_help_text() -> String {
90    let mut out = String::from("Analyzer options (paths for --analyzer-set PATH=VALUE):\n\n");
91    for descriptor in analyze_option_descriptors() {
92        let _ = writeln!(
93            out,
94            "- {}\n  default: {}\n  type: {}\n  affects: {}\n  description: {}",
95            descriptor.path,
96            descriptor.default_value,
97            descriptor.value_type,
98            descriptor.affects,
99            descriptor.description,
100        );
101        if let Some(note) = descriptor.increasing {
102            let _ = writeln!(out, "  increasing: {note}");
103        }
104        if let Some(note) = descriptor.decreasing {
105            let _ = writeln!(out, "  decreasing: {note}");
106        }
107        out.push('\n');
108    }
109    out
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn config_toml(trigger_permille: u64) -> String {
117        format!(
118            "[analyzer]\nschema_version=1\n[analyzer.queueing]\ntrigger_permille={trigger_permille}\n"
119        )
120    }
121
122    #[test]
123    fn default_options_without_config_or_overrides() {
124        let built = build_analyze_options(None, &[]).expect("build should succeed");
125        assert_eq!(built, AnalyzeOptions::default());
126    }
127
128    #[test]
129    fn config_toml_applies() {
130        let dir = tempfile::tempdir().expect("tempdir");
131        let path = dir.path().join("analyzer.toml");
132        std::fs::write(&path, config_toml(410)).expect("write config");
133
134        let built = build_analyze_options(Some(&path), &[]).expect("build should succeed");
135        assert_eq!(built.queueing.trigger_permille, 410);
136    }
137
138    #[test]
139    fn override_applies_and_beats_toml_last_wins() {
140        let dir = tempfile::tempdir().expect("tempdir");
141        let path = dir.path().join("analyzer.toml");
142        std::fs::write(&path, config_toml(410)).expect("write config");
143
144        let overrides = vec![
145            "queueing.trigger_permille=420".to_string(),
146            "queueing.trigger_permille=430".to_string(),
147        ];
148        let built = build_analyze_options(Some(&path), &overrides).expect("build should succeed");
149        assert_eq!(built.queueing.trigger_permille, 430);
150    }
151
152    #[test]
153    fn misspelled_path_reports_suggestion() {
154        let err = build_analyze_options(None, &["queuing.trigger_permille=400".to_string()])
155            .expect_err("expected error");
156        let msg = err.to_string();
157        assert!(msg.contains("queueing.trigger_permille"));
158    }
159
160    #[test]
161    fn invalid_type_reports_expected_type() {
162        let err = build_analyze_options(None, &["queueing.trigger_permille=abc".to_string()])
163            .expect_err("expected error");
164        let msg = err.to_string();
165        assert!(msg.contains("u64"));
166        assert!(matches!(err, CliAnalyzeConfigError::Analyzer(_)));
167    }
168
169    #[test]
170    fn missing_config_returns_read_config_error_with_path() {
171        let dir = tempfile::tempdir().expect("tempdir");
172        let missing_path = dir.path().join("missing-analyzer.toml");
173        let err = build_analyze_options(Some(&missing_path), &[]).expect_err("expected error");
174        let msg = err.to_string();
175        assert!(msg.contains(&format!(
176            "failed to read analyzer config '{}'",
177            missing_path.display()
178        )));
179        assert!(!msg.contains("analyzer.config_path"));
180        assert!(matches!(
181            err,
182            CliAnalyzeConfigError::ReadConfig { ref path, .. } if path == &missing_path
183        ));
184    }
185
186    #[test]
187    fn invalid_toml_returns_analyzer_error() {
188        let dir = tempfile::tempdir().expect("tempdir");
189        let path = dir.path().join("invalid-analyzer.toml");
190        std::fs::write(&path, "[analyzer.queueing\ntrigger_permille=410\n").expect("write config");
191        let err = build_analyze_options(Some(&path), &[]).expect_err("expected error");
192        assert!(matches!(err, CliAnalyzeConfigError::Analyzer(_)));
193    }
194}