Skip to main content

schwab_cli/ui/
discover.rs

1use std::env;
2use std::fs;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use inquire::Select;
7
8const RULES_ENV: &str = "SCHWAB_RULES";
9
10/// Candidate rules.yaml paths under `rules/` relative to cwd and the workspace.
11pub fn discover_rules_files() -> Vec<PathBuf> {
12    let mut found = Vec::new();
13    let mut seen = std::collections::HashSet::new();
14
15    let mut dirs = vec![PathBuf::from("rules")];
16    if let Ok(cwd) = env::current_dir() {
17        dirs.push(cwd.join("rules"));
18    }
19    let manifest_rules = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../rules");
20    if manifest_rules.is_dir() {
21        dirs.push(manifest_rules);
22    }
23
24    for dir in dirs {
25        if !dir.is_dir() {
26            continue;
27        }
28        let Ok(entries) = fs::read_dir(&dir) else {
29            continue;
30        };
31        let mut files: Vec<PathBuf> = entries
32            .filter_map(|e| e.ok())
33            .map(|e| e.path())
34            .filter(|p| {
35                p.extension()
36                    .and_then(|x| x.to_str())
37                    .is_some_and(|x| x == "yaml" || x == "yml")
38            })
39            .collect();
40        files.sort();
41        for path in files {
42            let canonical = path.canonicalize().unwrap_or(path);
43            if seen.insert(canonical.clone()) {
44                found.push(canonical);
45            }
46        }
47    }
48
49    found
50}
51
52/// Resolve rules file from explicit path, `SCHWAB_RULES`, discovery, or interactive pick.
53pub fn resolve_rules_file(explicit: Option<PathBuf>, interactive: bool) -> Result<PathBuf> {
54    if let Some(path) = explicit {
55        return Ok(path);
56    }
57
58    if let Ok(env_path) = env::var(RULES_ENV) {
59        let path = PathBuf::from(env_path);
60        if path.is_file() {
61            return Ok(path);
62        }
63        anyhow::bail!("{RULES_ENV} points to missing file: {}", path.display());
64    }
65
66    let candidates = discover_rules_files();
67    match candidates.len() {
68        0 => anyhow::bail!(
69            "no rules file specified — pass a path, set {RULES_ENV}, or add rules/*.yaml"
70        ),
71        1 => Ok(candidates.into_iter().next().unwrap()),
72        _ if interactive => {
73            let labels: Vec<String> = candidates.iter().map(|p| p.display().to_string()).collect();
74            let choice = Select::new("Select rules file", labels.clone())
75                .with_help_message("Multiple rules/*.yaml found")
76                .prompt()
77                .context("rules file selection cancelled")?;
78            let idx = labels
79                .iter()
80                .position(|l| l == &choice)
81                .context("invalid selection")?;
82            Ok(candidates[idx].clone())
83        }
84        _ => {
85            let listing = candidates
86                .iter()
87                .map(|p| format!("  - {}", p.display()))
88                .collect::<Vec<_>>()
89                .join("\n");
90            anyhow::bail!(
91                "multiple rules files found — pass one explicitly or set {RULES_ENV}:\n{listing}"
92            )
93        }
94    }
95}
96
97pub fn list_rules_files_display() -> String {
98    let files = discover_rules_files();
99    if files.is_empty() {
100        return "  (no rules/*.yaml found in ./rules)".into();
101    }
102    files
103        .iter()
104        .map(|p| format!("  • {}", p.display()))
105        .collect::<Vec<_>>()
106        .join("\n")
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn discover_finds_project_rules() {
115        let files = discover_rules_files();
116        assert!(
117            files
118                .iter()
119                .any(|p| p.to_string_lossy().contains("options-pilot")),
120            "expected project rules files, got {:?}",
121            files
122        );
123    }
124}