use std::collections::HashMap;
use std::path::{Path, PathBuf};
use sonda_core::BuiltinScenario;
#[derive(Debug)]
pub struct ScenarioCatalog {
entries: Vec<BuiltinScenario>,
}
impl ScenarioCatalog {
pub fn discover(search_path: &[PathBuf]) -> Self {
let mut seen: HashMap<String, usize> = HashMap::new();
let mut entries: Vec<BuiltinScenario> = Vec::new();
for dir in search_path {
if !dir.is_dir() {
continue;
}
let read_dir = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) => {
eprintln!(
"warning: cannot read scenario directory {}: {}",
dir.display(),
e
);
continue;
}
};
for dir_entry in read_dir {
let dir_entry = match dir_entry {
Ok(de) => de,
Err(e) => {
eprintln!("warning: error reading entry in {}: {}", dir.display(), e);
continue;
}
};
let path = dir_entry.path();
let meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(e) => {
eprintln!("warning: cannot stat {}: {}", path.display(), e);
continue;
}
};
if !meta.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
if ext != Some("yaml") && ext != Some("yml") {
continue;
}
let normalized = match normalize_filename(&path) {
Some(n) => n,
None => continue,
};
if seen.contains_key(&normalized) {
continue;
}
match read_scenario_metadata(&path) {
Ok(entry) => {
seen.insert(normalized, entries.len());
entries.push(entry);
}
Err(e) => {
eprintln!("warning: skipping {}: {}", path.display(), e);
}
}
}
}
ScenarioCatalog { entries }
}
pub fn list(&self) -> &[BuiltinScenario] {
&self.entries
}
pub fn list_by_category(&self, category: &str) -> Vec<&BuiltinScenario> {
self.entries
.iter()
.filter(|e| e.category == category)
.collect()
}
pub fn find(&self, name: &str) -> Option<&BuiltinScenario> {
let query = name.replace('-', "_");
self.entries
.iter()
.find(|e| e.name.replace('-', "_") == query)
}
pub fn available_names(&self) -> Vec<&str> {
self.entries.iter().map(|e| e.name.as_str()).collect()
}
pub fn read_yaml(&self, name: &str) -> Option<Result<String, std::io::Error>> {
self.find(name)
.map(|entry| std::fs::read_to_string(&entry.source_path))
}
}
pub fn build_search_path(cli_scenario_path: Option<&Path>) -> Vec<PathBuf> {
if let Some(p) = cli_scenario_path {
return vec![p.to_path_buf()];
}
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(env_val) = std::env::var("SONDA_SCENARIO_PATH") {
for segment in env_val.split(':') {
let trimmed = segment.trim();
if !trimmed.is_empty() {
dirs.push(PathBuf::from(trimmed));
}
}
}
dirs.push(PathBuf::from("./scenarios"));
if let Some(home) = home_dir() {
dirs.push(home.join(".sonda").join("scenarios"));
}
dirs
}
fn normalize_filename(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
Some(stem.replace('-', "_"))
}
fn read_scenario_metadata(path: &Path) -> Result<BuiltinScenario, String> {
let content = std::fs::read_to_string(path).map_err(|e| format!("cannot read file: {e}"))?;
#[derive(serde::Deserialize)]
struct Probe {
scenario_name: Option<String>,
category: Option<String>,
signal_type: Option<String>,
description: Option<String>,
scenarios: Option<Vec<EntrySignalProbe>>,
}
#[derive(serde::Deserialize)]
struct EntrySignalProbe {
signal_type: Option<String>,
}
let probe: Probe =
serde_yaml_ng::from_str(&content).map_err(|e| format!("invalid YAML: {e}"))?;
let filename_stem = normalize_filename(path)
.ok_or_else(|| "cannot determine scenario name from filename".to_string())?;
let name = probe
.scenario_name
.filter(|n| !n.is_empty())
.unwrap_or_else(|| filename_stem.replace('_', "-"));
let category = probe
.category
.unwrap_or_else(|| "uncategorized".to_string());
let signal_type = probe
.signal_type
.or_else(|| {
let entries = probe.scenarios?;
if entries.len() > 1 {
Some("multi".to_string())
} else {
entries
.into_iter()
.next()
.and_then(|entry| entry.signal_type)
}
})
.unwrap_or_else(|| "metrics".to_string());
let description = probe.description.unwrap_or_default();
let source_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
Ok(BuiltinScenario {
name,
category,
signal_type,
description,
source_path,
})
}
fn home_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(windows)]
{
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
#[cfg(not(any(unix, windows)))]
{
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_scenario_dir(suffix: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"sonda-scenarios-test-{suffix}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("must create temp dir");
dir
}
fn write_scenario(dir: &Path, filename: &str, content: &str) {
fs::write(dir.join(filename), content).expect("must write scenario");
}
fn valid_scenario_yaml(name: &str, category: &str, signal_type: &str) -> String {
format!(
r#"scenario_name: {name}
category: {category}
signal_type: {signal_type}
description: "Test scenario for {name}"
name: test_metric
rate: 1
duration: 10s
generator:
type: constant
value: 1.0
encoder:
type: prometheus_text
sink:
type: stdout
"#
)
}
#[test]
fn normalize_filename_strips_yaml_extension() {
assert_eq!(
normalize_filename(Path::new("cpu-spike.yaml")),
Some("cpu_spike".to_string())
);
}
#[test]
fn normalize_filename_strips_yml_extension() {
assert_eq!(
normalize_filename(Path::new("memory-leak.yml")),
Some("memory_leak".to_string())
);
}
#[test]
fn normalize_filename_preserves_underscores() {
assert_eq!(
normalize_filename(Path::new("already_snake.yaml")),
Some("already_snake".to_string())
);
}
#[test]
fn discover_empty_directory_produces_empty_catalog() {
let dir = temp_scenario_dir("empty");
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert!(catalog.list().is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn discover_valid_scenario_found() {
let dir = temp_scenario_dir("valid");
write_scenario(
&dir,
"cpu-spike.yaml",
&valid_scenario_yaml("cpu-spike", "infrastructure", "metrics"),
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].name, "cpu-spike");
assert_eq!(catalog.list()[0].category, "infrastructure");
assert_eq!(catalog.list()[0].signal_type, "metrics");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn discover_skips_non_yaml_files() {
let dir = temp_scenario_dir("non-yaml");
write_scenario(&dir, "readme.txt", "not a scenario");
write_scenario(&dir, "data.json", "{}");
write_scenario(
&dir,
"good.yaml",
&valid_scenario_yaml("good", "test", "metrics"),
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].name, "good");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn discover_skips_invalid_yaml_without_crashing() {
let dir = temp_scenario_dir("invalid-yaml");
write_scenario(&dir, "bad.yaml", "not: valid: yaml: :::");
write_scenario(
&dir,
"good.yaml",
&valid_scenario_yaml("good", "test", "metrics"),
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].name, "good");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn discover_nonexistent_directory_silently_skipped() {
let catalog =
ScenarioCatalog::discover(&[PathBuf::from("/nonexistent/path/for/scenario/testing")]);
assert!(catalog.list().is_empty());
}
#[test]
fn discover_name_collision_first_match_wins() {
let dir1 = temp_scenario_dir("prio-high");
let dir2 = temp_scenario_dir("prio-low");
write_scenario(
&dir1,
"my-scenario.yaml",
&format!(
r#"scenario_name: my-scenario
description: "high priority"
category: high
signal_type: metrics
name: test
rate: 1
generator:
type: constant
value: 1.0
encoder:
type: prometheus_text
sink:
type: stdout
"#
),
);
write_scenario(
&dir2,
"my-scenario.yaml",
&format!(
r#"scenario_name: my-scenario
description: "low priority"
category: low
signal_type: metrics
name: test
rate: 1
generator:
type: constant
value: 1.0
encoder:
type: prometheus_text
sink:
type: stdout
"#
),
);
let catalog = ScenarioCatalog::discover(&[dir1.clone(), dir2.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].description, "high priority");
let _ = fs::remove_dir_all(&dir1);
let _ = fs::remove_dir_all(&dir2);
}
#[test]
fn find_by_name() {
let dir = temp_scenario_dir("find");
write_scenario(
&dir,
"cpu-spike.yaml",
&valid_scenario_yaml("cpu-spike", "infrastructure", "metrics"),
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert!(catalog.find("cpu-spike").is_some());
assert!(catalog.find("cpu_spike").is_some());
assert!(catalog.find("nonexistent").is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn list_by_category_filters_correctly() {
let dir = temp_scenario_dir("category");
write_scenario(
&dir,
"scenario-a.yaml",
&valid_scenario_yaml("scenario-a", "network", "metrics"),
);
write_scenario(
&dir,
"scenario-b.yaml",
&valid_scenario_yaml("scenario-b", "application", "logs"),
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
let network = catalog.list_by_category("network");
assert_eq!(network.len(), 1);
assert_eq!(network[0].name, "scenario-a");
let app = catalog.list_by_category("application");
assert_eq!(app.len(), 1);
assert_eq!(app[0].name, "scenario-b");
let empty = catalog.list_by_category("nonexistent");
assert!(empty.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn read_yaml_returns_file_content() {
let dir = temp_scenario_dir("read-yaml");
let yaml = valid_scenario_yaml("read-test", "test", "metrics");
write_scenario(&dir, "read-test.yaml", &yaml);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
let content = catalog
.read_yaml("read-test")
.expect("scenario must be in catalog")
.expect("file must be readable");
assert!(content.contains("read-test"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn read_yaml_unknown_name_returns_none() {
let catalog = ScenarioCatalog::discover(&[]);
assert!(catalog.read_yaml("nonexistent").is_none());
}
#[test]
fn build_search_path_cli_flag_overrides_all() {
let path = build_search_path(Some(Path::new("/custom/scenarios")));
assert_eq!(path, vec![PathBuf::from("/custom/scenarios")]);
}
#[test]
fn build_search_path_default_includes_cwd_scenarios() {
let path = build_search_path(None);
assert!(
path.iter().any(|p| p.ends_with("scenarios")),
"default search path must include a 'scenarios' directory"
);
}
#[test]
fn available_names_matches_catalog_count() {
let dir = temp_scenario_dir("avail-names");
write_scenario(&dir, "a.yaml", &valid_scenario_yaml("a", "test", "metrics"));
write_scenario(&dir, "b.yaml", &valid_scenario_yaml("b", "test", "metrics"));
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.available_names().len(), catalog.list().len());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn scenario_without_metadata_uses_filename_defaults() {
let dir = temp_scenario_dir("no-meta");
write_scenario(
&dir,
"my-scenario.yaml",
r#"name: test_metric
rate: 1
generator:
type: constant
value: 1.0
encoder:
type: prometheus_text
sink:
type: stdout
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
let entry = &catalog.list()[0];
assert_eq!(entry.name, "my-scenario");
assert_eq!(entry.category, "uncategorized");
assert_eq!(entry.signal_type, "metrics");
assert!(entry.description.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v2_scenario_first_entry_signal_type_logs_wins_when_root_absent() {
let dir = temp_scenario_dir("v2-entry-logs");
write_scenario(
&dir,
"log-storm.yaml",
r#"version: 2
scenario_name: log-storm
category: application
description: "v2 log storm"
scenarios:
- name: bursty_logs
signal_type: logs
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "logs");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v2_scenario_first_entry_signal_type_histogram_wins_when_root_absent() {
let dir = temp_scenario_dir("v2-entry-histogram");
write_scenario(
&dir,
"histogram-latency.yaml",
r#"version: 2
scenario_name: histogram-latency
category: application
description: "v2 histogram latency"
scenarios:
- name: latency_buckets
signal_type: histogram
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "histogram");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v2_scenario_root_signal_type_wins_over_first_entry() {
let dir = temp_scenario_dir("v2-root-wins");
write_scenario(
&dir,
"mixed.yaml",
r#"version: 2
scenario_name: mixed
category: infrastructure
signal_type: metrics
description: "root metrics overrides entry logs"
scenarios:
- name: some_logs
signal_type: logs
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "metrics");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v1_scenario_root_signal_type_logs_preserved_without_entries() {
let dir = temp_scenario_dir("v1-root-logs");
write_scenario(
&dir,
"legacy-logs.yaml",
r#"scenario_name: legacy-logs
category: application
signal_type: logs
description: "v1 log scenario"
name: test_log
rate: 1
generator:
type: constant
value: 1.0
encoder:
type: json
sink:
type: stdout
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "logs");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v1_scenario_without_any_signal_type_defaults_to_metrics() {
let dir = temp_scenario_dir("v1-no-signal");
write_scenario(
&dir,
"untyped.yaml",
r#"scenario_name: untyped
category: uncategorized
description: "no signal_type anywhere"
name: test_metric
rate: 1
generator:
type: constant
value: 1.0
encoder:
type: prometheus_text
sink:
type: stdout
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "metrics");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v2_scenario_empty_scenarios_list_defaults_to_metrics() {
let dir = temp_scenario_dir("v2-empty-scenarios");
write_scenario(
&dir,
"empty.yaml",
r#"version: 2
scenario_name: empty
category: infrastructure
description: "v2 with empty scenarios list"
scenarios: []
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "metrics");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v2_scenario_three_entries_reports_multi_when_root_absent() {
let dir = temp_scenario_dir("v2-multi-three");
write_scenario(
&dir,
"link-failure.yaml",
r#"version: 2
scenario_name: link-failure
category: network
description: "v2 multi-signal link failure"
scenarios:
- name: iface_down
signal_type: metrics
- name: bgp_flap
signal_type: metrics
- name: link_recover
signal_type: metrics
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "multi");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v2_scenario_two_entries_reports_multi_at_boundary() {
let dir = temp_scenario_dir("v2-multi-two");
write_scenario(
&dir,
"interface-flap.yaml",
r#"version: 2
scenario_name: interface-flap
category: network
description: "v2 two-entry multi boundary"
scenarios:
- name: iface_up
signal_type: metrics
- name: iface_down
signal_type: metrics
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "multi");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v2_scenario_single_entry_does_not_trigger_multi() {
let dir = temp_scenario_dir("v2-single-not-multi");
write_scenario(
&dir,
"solo.yaml",
r#"version: 2
scenario_name: solo
category: infrastructure
description: "v2 single entry falls through to first-entry branch"
scenarios:
- name: solo_metric
signal_type: metrics
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "metrics");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v1_scenario_root_multi_preserved_with_multi_entry_list() {
let dir = temp_scenario_dir("v1-root-multi");
write_scenario(
&dir,
"legacy-link-failure.yaml",
r#"scenario_name: legacy-link-failure
category: network
signal_type: multi
description: "v1 multi with explicit root"
scenarios:
- name: a
signal_type: metrics
- name: b
signal_type: metrics
- name: c
signal_type: metrics
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "multi");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn v1_scenario_root_metrics_wins_over_multi_entry_detection() {
let dir = temp_scenario_dir("v1-root-wins-over-multi");
write_scenario(
&dir,
"forced-metrics.yaml",
r#"scenario_name: forced-metrics
category: infrastructure
signal_type: metrics
description: "v1 root metrics overrides multi-entry detection"
scenarios:
- name: one
signal_type: logs
- name: two
signal_type: logs
"#,
);
let catalog = ScenarioCatalog::discover(&[dir.clone()]);
assert_eq!(catalog.list().len(), 1);
assert_eq!(catalog.list()[0].signal_type, "metrics");
let _ = fs::remove_dir_all(&dir);
}
}