Skip to main content

rippy_cli/
profile_cmd.rs

1//! CLI commands for managing safety packages.
2//!
3//! Provides `rippy profile list`, `rippy profile show`, and `rippy profile set`.
4
5use std::fmt::Write as _;
6use std::path::Path;
7use std::process::ExitCode;
8
9use serde::Serialize;
10
11use crate::cli::{ProfileArgs, ProfileTarget};
12use crate::config::{self, ConfigDirective};
13use crate::error::RippyError;
14use crate::packages::{self, Package};
15
16/// Run the profile subcommand.
17///
18/// # Errors
19///
20/// Returns `RippyError` on config I/O failures or invalid package names.
21pub fn run(args: &ProfileArgs) -> Result<ExitCode, RippyError> {
22    match &args.target {
23        ProfileTarget::List { json } => list_profiles(*json),
24        ProfileTarget::Show { name, json } => show_profile(name, *json),
25        ProfileTarget::Set { name, project } => set_profile(name, *project),
26    }
27}
28
29// ---------------------------------------------------------------------------
30// List
31// ---------------------------------------------------------------------------
32
33#[derive(Debug, Serialize)]
34struct ProfileListEntry {
35    name: String,
36    shield: String,
37    tagline: String,
38    active: bool,
39}
40
41fn list_profiles(json: bool) -> Result<ExitCode, RippyError> {
42    let active = active_package_name();
43
44    if json {
45        let entries: Vec<ProfileListEntry> = Package::all()
46            .iter()
47            .map(|p| ProfileListEntry {
48                name: p.name().to_string(),
49                shield: p.shield().to_string(),
50                tagline: p.tagline().to_string(),
51                active: active.as_deref() == Some(p.name()),
52            })
53            .collect();
54        let out = serde_json::to_string_pretty(&entries)
55            .map_err(|e| RippyError::Setup(format!("JSON error: {e}")))?;
56        println!("{out}");
57        return Ok(ExitCode::SUCCESS);
58    }
59
60    for pkg in Package::all() {
61        let marker = if active.as_deref() == Some(pkg.name()) {
62            "  (active)"
63        } else {
64            ""
65        };
66        println!(
67            "  {:<12}[{}]     {}{marker}",
68            pkg.name(),
69            pkg.shield(),
70            pkg.tagline(),
71        );
72    }
73    Ok(ExitCode::SUCCESS)
74}
75
76/// Read the currently active package from the merged config.
77fn active_package_name() -> Option<String> {
78    let cwd = std::env::current_dir().unwrap_or_default();
79    let config = config::Config::load(&cwd, None).ok()?;
80    config.active_package.map(|p| p.name().to_string())
81}
82
83// ---------------------------------------------------------------------------
84// Show
85// ---------------------------------------------------------------------------
86
87#[derive(Debug, Serialize)]
88struct ProfileShowOutput {
89    name: String,
90    shield: String,
91    tagline: String,
92    rules: Vec<RuleDisplay>,
93    git_style: Option<String>,
94    git_branches: Vec<BranchDisplay>,
95}
96
97#[derive(Debug, Serialize)]
98struct RuleDisplay {
99    action: String,
100    description: String,
101}
102
103#[derive(Debug, Serialize)]
104struct BranchDisplay {
105    pattern: String,
106    style: String,
107}
108
109fn show_profile(name: &str, json: bool) -> Result<ExitCode, RippyError> {
110    let package = Package::parse(name).map_err(RippyError::Setup)?;
111    let directives = packages::package_directives(package)?;
112
113    let rules = extract_rule_displays(&directives);
114    let (git_style, git_branches) = extract_git_info(package);
115
116    if json {
117        let output = ProfileShowOutput {
118            name: package.name().to_string(),
119            shield: package.shield().to_string(),
120            tagline: package.tagline().to_string(),
121            rules,
122            git_style,
123            git_branches,
124        };
125        let out = serde_json::to_string_pretty(&output)
126            .map_err(|e| RippyError::Setup(format!("JSON error: {e}")))?;
127        println!("{out}");
128        return Ok(ExitCode::SUCCESS);
129    }
130
131    println!("Package: {} [{}]", package.name(), package.shield());
132    println!("  \"{}\"", package.tagline());
133    println!();
134
135    if !rules.is_empty() {
136        println!("  Rules:");
137        for rule in &rules {
138            println!("    {:<6} {}", rule.action, rule.description);
139        }
140        println!();
141    }
142
143    if let Some(style) = &git_style {
144        let mut git_line = format!("  Git: {style}");
145        if !git_branches.is_empty() {
146            let _ = write!(git_line, " (");
147            for (i, b) in git_branches.iter().enumerate() {
148                if i > 0 {
149                    let _ = write!(git_line, ", ");
150                }
151                let _ = write!(git_line, "{} on {}", b.style, b.pattern);
152            }
153            let _ = write!(git_line, ")");
154        }
155        println!("{git_line}");
156    }
157
158    Ok(ExitCode::SUCCESS)
159}
160
161fn extract_rule_displays(directives: &[ConfigDirective]) -> Vec<RuleDisplay> {
162    directives
163        .iter()
164        .filter_map(|d| {
165            if let ConfigDirective::Rule(r) = d {
166                Some(RuleDisplay {
167                    action: r.decision.as_str().to_string(),
168                    description: format_rule_description(r),
169                })
170            } else {
171                None
172            }
173        })
174        .collect()
175}
176
177fn format_rule_description(r: &crate::config::Rule) -> String {
178    // Prefer structured matching fields over raw pattern.
179    if let Some(cmd) = &r.command {
180        let mut desc = cmd.clone();
181        if let Some(sub) = &r.subcommand {
182            desc = format!("{desc} {sub}");
183        } else if let Some(subs) = &r.subcommands {
184            desc = format!("{desc} {}", subs.join(", "));
185        }
186        if let Some(flags) = &r.flags {
187            desc = format!("{desc} [{}]", flags.join(", "));
188        }
189        if let Some(ac) = &r.args_contain {
190            desc = format!("{desc} (args contain \"{ac}\")");
191        }
192        if let Some(msg) = &r.message {
193            desc = format!("{desc}  \"{msg}\"");
194        }
195        return desc;
196    }
197
198    let raw = r.pattern.raw();
199    r.message
200        .as_ref()
201        .map_or_else(|| raw.to_string(), |msg| format!("{raw}  \"{msg}\""))
202}
203
204fn extract_git_info(package: Package) -> (Option<String>, Vec<BranchDisplay>) {
205    let source = packages::package_toml(package);
206    let config: crate::toml_config::TomlConfig = match toml::from_str(source) {
207        Ok(c) => c,
208        Err(_) => return (None, Vec::new()),
209    };
210    let Some(git) = config.git else {
211        return (None, Vec::new());
212    };
213    let branches = git
214        .branches
215        .iter()
216        .map(|b| BranchDisplay {
217            pattern: b.pattern.clone(),
218            style: b.style.clone(),
219        })
220        .collect();
221    (git.style, branches)
222}
223
224// ---------------------------------------------------------------------------
225// Set
226// ---------------------------------------------------------------------------
227
228fn set_profile(name: &str, project: bool) -> Result<ExitCode, RippyError> {
229    let _ = Package::parse(name).map_err(RippyError::Setup)?;
230
231    let path = resolve_config_path(project)?;
232    write_package_setting(&path, name)?;
233
234    if project {
235        crate::trust::TrustGuard::before_write(&path).commit();
236    }
237    eprintln!("[rippy] Package set to \"{name}\" in {}", path.display());
238
239    Ok(ExitCode::SUCCESS)
240}
241
242fn resolve_config_path(project: bool) -> Result<std::path::PathBuf, RippyError> {
243    if project {
244        Ok(std::path::PathBuf::from(".rippy.toml"))
245    } else {
246        config::home_dir()
247            .map(|h| h.join(".rippy/config.toml"))
248            .ok_or_else(|| RippyError::Setup("could not determine home directory".into()))
249    }
250}
251
252/// Write `package = "<name>"` to a TOML config file.
253///
254/// If the file has an existing `package = ` line, it is replaced.
255/// If the file has a `[settings]` section but no package, the line is inserted.
256/// Otherwise, `[settings]\npackage = "<name>"` is prepended.
257///
258/// # Errors
259///
260/// Returns `RippyError::Setup` if the file cannot be read or written.
261pub fn write_package_setting(path: &Path, package_name: &str) -> Result<(), RippyError> {
262    if let Some(parent) = path.parent()
263        && !parent.as_os_str().is_empty()
264    {
265        std::fs::create_dir_all(parent).map_err(|e| {
266            RippyError::Setup(format!("could not create {}: {e}", parent.display()))
267        })?;
268    }
269
270    let existing = match std::fs::read_to_string(path) {
271        Ok(s) => s,
272        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
273        Err(e) => {
274            return Err(RippyError::Setup(format!(
275                "could not read {}: {e}",
276                path.display()
277            )));
278        }
279    };
280
281    let new_line = format!("package = \"{package_name}\"");
282    let content = update_package_in_content(&existing, &new_line);
283
284    std::fs::write(path, content)
285        .map_err(|e| RippyError::Setup(format!("could not write {}: {e}", path.display())))
286}
287
288fn is_package_setting_line(line: &str) -> bool {
289    let trimmed = line.trim();
290    trimmed.starts_with("package =") || trimmed.starts_with("package=")
291}
292
293fn update_package_in_content(existing: &str, new_line: &str) -> String {
294    // Case 1: Replace existing package line.
295    if existing.lines().any(is_package_setting_line) {
296        return existing
297            .lines()
298            .map(|l| {
299                if is_package_setting_line(l) {
300                    new_line.to_string()
301                } else {
302                    l.to_string()
303                }
304            })
305            .collect::<Vec<_>>()
306            .join("\n")
307            + if existing.ends_with('\n') { "\n" } else { "" };
308    }
309
310    // Case 2: Has [settings] section — insert after it.
311    if existing.contains("[settings]") {
312        return existing
313            .lines()
314            .flat_map(|l| {
315                if l.trim() == "[settings]" {
316                    vec![l.to_string(), new_line.to_string()]
317                } else {
318                    vec![l.to_string()]
319                }
320            })
321            .collect::<Vec<_>>()
322            .join("\n")
323            + if existing.ends_with('\n') { "\n" } else { "" };
324    }
325
326    // Case 3: No settings section — prepend one.
327    if existing.is_empty() {
328        format!("[settings]\n{new_line}\n")
329    } else {
330        format!("[settings]\n{new_line}\n\n{existing}")
331    }
332}
333
334#[cfg(test)]
335#[allow(clippy::unwrap_used)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn update_empty_file() {
341        let result = update_package_in_content("", "package = \"develop\"");
342        assert_eq!(result, "[settings]\npackage = \"develop\"\n");
343    }
344
345    #[test]
346    fn update_existing_package_line() {
347        let existing = "[settings]\npackage = \"review\"\n";
348        let result = update_package_in_content(existing, "package = \"develop\"");
349        assert!(result.contains("package = \"develop\""));
350        assert!(!result.contains("review"));
351    }
352
353    #[test]
354    fn update_settings_section_no_package() {
355        let existing = "[settings]\ndefault = \"ask\"\n";
356        let result = update_package_in_content(existing, "package = \"develop\"");
357        assert!(result.contains("[settings]"));
358        assert!(result.contains("package = \"develop\""));
359        assert!(result.contains("default = \"ask\""));
360    }
361
362    #[test]
363    fn update_no_settings_section() {
364        let existing = "[[rules]]\naction = \"allow\"\ncommand = \"ls\"\n";
365        let result = update_package_in_content(existing, "package = \"develop\"");
366        assert!(result.starts_with("[settings]\npackage = \"develop\""));
367        assert!(result.contains("[[rules]]"));
368    }
369
370    #[test]
371    fn update_does_not_clobber_similar_keys() {
372        // Keys like package_version should not be matched by the package replacement.
373        let existing = "[settings]\npackage_version = \"1.0\"\ndefault = \"ask\"\n";
374        let result = update_package_in_content(existing, "package = \"develop\"");
375        assert!(
376            result.contains("package_version = \"1.0\""),
377            "package_version should be preserved, got: {result}"
378        );
379        assert!(result.contains("package = \"develop\""));
380    }
381
382    #[test]
383    fn update_handles_no_space_before_equals() {
384        let existing = "[settings]\npackage=\"review\"\n";
385        let result = update_package_in_content(existing, "package = \"develop\"");
386        assert!(result.contains("package = \"develop\""));
387        assert!(!result.contains("review"));
388    }
389
390    #[test]
391    fn write_package_creates_file() {
392        let dir = tempfile::tempdir().unwrap();
393        let path = dir.path().join("config.toml");
394        write_package_setting(&path, "develop").unwrap();
395
396        let content = std::fs::read_to_string(&path).unwrap();
397        assert!(content.contains("package = \"develop\""));
398        assert!(content.contains("[settings]"));
399    }
400
401    #[test]
402    fn write_package_updates_existing() {
403        let dir = tempfile::tempdir().unwrap();
404        let path = dir.path().join("config.toml");
405        std::fs::write(&path, "[settings]\npackage = \"review\"\n").unwrap();
406
407        write_package_setting(&path, "autopilot").unwrap();
408
409        let content = std::fs::read_to_string(&path).unwrap();
410        assert!(content.contains("package = \"autopilot\""));
411        assert!(!content.contains("review"));
412    }
413}