Skip to main content

rippy_cli/
migrate.rs

1//! Convert legacy `.rippy` config files to TOML format.
2
3use std::path::{Path, PathBuf};
4use std::process::ExitCode;
5
6use crate::cli::MigrateArgs;
7use crate::config::ConfigDirective;
8use crate::error::RippyError;
9use crate::toml_config::rules_to_toml;
10
11/// Run the `rippy migrate` subcommand.
12///
13/// # Errors
14///
15/// Returns `RippyError` if the input config cannot be read/parsed
16/// or the output cannot be written.
17pub fn run(args: &MigrateArgs) -> Result<ExitCode, RippyError> {
18    let input_path = resolve_input_path(args)?;
19    let rules = parse_legacy_file(&input_path)?;
20    let toml_output = rules_to_toml(&rules);
21
22    if args.stdout {
23        print!("{toml_output}");
24    } else {
25        let output_path = derive_output_path(&input_path);
26        std::fs::write(&output_path, &toml_output).map_err(|e| {
27            RippyError::Setup(format!("could not write {}: {e}", output_path.display()))
28        })?;
29        crate::trust::TrustGuard::for_new_file(&output_path).commit();
30        eprintln!(
31            "[rippy] Converted {} -> {}",
32            input_path.display(),
33            output_path.display()
34        );
35    }
36
37    Ok(ExitCode::SUCCESS)
38}
39
40/// Resolve the input config file path.
41fn resolve_input_path(args: &MigrateArgs) -> Result<PathBuf, RippyError> {
42    if let Some(path) = &args.path {
43        return Ok(path.clone());
44    }
45
46    // Walk up from cwd looking for .rippy or .dippy (legacy formats only).
47    let cwd = std::env::current_dir().map_err(|e| RippyError::Setup(format!("no cwd: {e}")))?;
48    let mut dir = cwd.as_path();
49    loop {
50        let rippy = dir.join(".rippy");
51        if rippy.is_file() {
52            return Ok(rippy);
53        }
54        let dippy = dir.join(".dippy");
55        if dippy.is_file() {
56            return Ok(dippy);
57        }
58        dir = dir
59            .parent()
60            .ok_or_else(|| RippyError::Setup("no .rippy or .dippy config found".to_string()))?;
61    }
62}
63
64/// Derive the output `.rippy.toml` path from the input path.
65fn derive_output_path(input: &Path) -> PathBuf {
66    input
67        .parent()
68        .unwrap_or_else(|| Path::new("."))
69        .join(".rippy.toml")
70}
71
72/// Parse a legacy line-based config file into directives.
73fn parse_legacy_file(path: &Path) -> Result<Vec<ConfigDirective>, RippyError> {
74    let content = std::fs::read_to_string(path).map_err(|e| RippyError::Config {
75        path: path.to_owned(),
76        line: 0,
77        message: format!("could not read: {e}"),
78    })?;
79
80    let mut directives = Vec::new();
81    for (line_num, line) in content.lines().enumerate() {
82        let line = line.trim();
83        if line.is_empty() || line.starts_with('#') {
84            continue;
85        }
86        let directive = crate::config::parse_rule(line).map_err(|msg| RippyError::Config {
87            path: path.to_owned(),
88            line: line_num + 1,
89            message: msg,
90        })?;
91        directives.push(directive);
92    }
93
94    Ok(directives)
95}
96
97#[cfg(test)]
98#[allow(clippy::unwrap_used)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn migrate_basic_config() {
104        let dir = tempfile::TempDir::new().unwrap();
105        let input = dir.path().join(".rippy");
106        std::fs::write(
107            &input,
108            "allow git status\ndeny rm -rf \"use trash instead\"\nset default ask\n",
109        )
110        .unwrap();
111
112        let directives = parse_legacy_file(&input).unwrap();
113        let toml = rules_to_toml(&directives);
114
115        // Verify the TOML is valid and round-trips.
116        let re_parsed =
117            crate::toml_config::parse_toml_config(&toml, Path::new("test.toml")).unwrap();
118        let config = crate::config::Config::from_directives(re_parsed);
119        assert_eq!(
120            config.match_command("git status", None).unwrap().decision,
121            crate::verdict::Decision::Allow,
122        );
123        assert_eq!(
124            config.match_command("rm -rf /tmp", None).unwrap().decision,
125            crate::verdict::Decision::Deny,
126        );
127        assert_eq!(config.default_action, Some(crate::verdict::Decision::Ask));
128    }
129
130    #[test]
131    fn derive_output_path_sibling() {
132        let out = derive_output_path(Path::new("/home/user/project/.rippy"));
133        assert_eq!(out, PathBuf::from("/home/user/project/.rippy.toml"));
134    }
135
136    #[test]
137    fn derive_output_path_bare() {
138        let out = derive_output_path(Path::new(".rippy"));
139        assert_eq!(out, PathBuf::from(".rippy.toml"));
140    }
141}