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