1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::fmt::Write as _;
8use std::path::Path;
9
10use tailtriage_analyzer::{analyze_option_descriptors, AnalyzeConfigError, AnalyzeOptions};
11
12pub mod artifact;
14
15#[derive(Debug)]
17pub enum CliAnalyzeConfigError {
18 ReadConfig {
20 path: std::path::PathBuf,
22 source: std::io::Error,
24 },
25 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
59pub 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#[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}