schwab_cli/ui/
discover.rs1use 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
10pub 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
52pub 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}