Skip to main content

rippy_cli/
stdlib.rs

1//! Embedded stdlib rules — default safety rules shipped with the binary.
2//!
3//! These are loaded as the lowest-priority tier in the config system.
4//! User and project config override stdlib rules via last-match-wins.
5
6use std::io::Write as _;
7use std::path::Path;
8use std::process::ExitCode;
9
10use crate::cli::InitArgs;
11use crate::config::{self, ConfigDirective};
12use crate::error::RippyError;
13use crate::packages::Package;
14
15// Simple tool rules (split from simple.toml)
16const CARGO_TOML: &str = include_str!("stdlib/cargo.toml");
17const BREW_TOML: &str = include_str!("stdlib/brew.toml");
18const PIP_TOML: &str = include_str!("stdlib/pip.toml");
19const TERRAFORM_TOML: &str = include_str!("stdlib/terraform.toml");
20const PYTEST_TOML: &str = include_str!("stdlib/pytest.toml");
21const MAKE_TOML: &str = include_str!("stdlib/make.toml");
22const RUSTUP_TOML: &str = include_str!("stdlib/rustup.toml");
23const OPENSSL_TOML: &str = include_str!("stdlib/openssl.toml");
24
25// File operations
26const FILE_OPS_TOML: &str = include_str!("stdlib/file_ops.toml");
27
28// Dangerous command rules (split from dangerous.toml)
29const BUILTINS_TOML: &str = include_str!("stdlib/builtins.toml");
30const SUDO_TOML: &str = include_str!("stdlib/sudo.toml");
31const SSH_TOML: &str = include_str!("stdlib/ssh.toml");
32const INTERPRETERS_TOML: &str = include_str!("stdlib/interpreters.toml");
33const PACKAGE_MANAGERS_TOML: &str = include_str!("stdlib/package_managers.toml");
34
35/// All embedded stdlib TOML sources in loading order.
36const STDLIB_SOURCES: &[(&str, &str)] = &[
37    // Simple tools
38    ("(stdlib:cargo)", CARGO_TOML),
39    ("(stdlib:brew)", BREW_TOML),
40    ("(stdlib:pip)", PIP_TOML),
41    ("(stdlib:terraform)", TERRAFORM_TOML),
42    ("(stdlib:pytest)", PYTEST_TOML),
43    ("(stdlib:make)", MAKE_TOML),
44    ("(stdlib:rustup)", RUSTUP_TOML),
45    ("(stdlib:openssl)", OPENSSL_TOML),
46    // File operations
47    ("(stdlib:file_ops)", FILE_OPS_TOML),
48    // Dangerous commands
49    ("(stdlib:builtins)", BUILTINS_TOML),
50    ("(stdlib:sudo)", SUDO_TOML),
51    ("(stdlib:ssh)", SSH_TOML),
52    ("(stdlib:interpreters)", INTERPRETERS_TOML),
53    ("(stdlib:package_managers)", PACKAGE_MANAGERS_TOML),
54];
55
56/// Parse all embedded stdlib TOML into config directives.
57///
58/// # Errors
59///
60/// Returns `RippyError::Config` if any embedded TOML is malformed (a build bug).
61pub fn stdlib_directives() -> Result<Vec<ConfigDirective>, RippyError> {
62    let mut directives = Vec::new();
63    for (label, source) in STDLIB_SOURCES {
64        let parsed = crate::toml_config::parse_toml_config(source, Path::new(label))?;
65        directives.extend(parsed);
66    }
67    Ok(directives)
68}
69
70/// Return the concatenated raw TOML for all stdlib files.
71#[must_use]
72pub fn stdlib_toml() -> String {
73    let mut out = String::new();
74    for (_, source) in STDLIB_SOURCES {
75        out.push_str(source);
76        out.push('\n');
77    }
78    out
79}
80
81/// Run the `rippy init` command — create config with a safety package.
82///
83/// # Errors
84///
85/// Returns `RippyError::Setup` if the file cannot be written.
86pub fn run_init(args: &InitArgs) -> Result<ExitCode, RippyError> {
87    if args.stdout {
88        print!("{}", stdlib_toml());
89        return Ok(ExitCode::SUCCESS);
90    }
91
92    let package = resolve_init_package(args)?;
93
94    let path = if args.global {
95        config::home_dir()
96            .map(|h| h.join(".rippy/config.toml"))
97            .ok_or_else(|| RippyError::Setup("could not determine home directory".into()))?
98    } else {
99        std::path::PathBuf::from(".rippy.toml")
100    };
101
102    if path.exists() {
103        return Err(RippyError::Setup(format!(
104            "{} already exists. Remove it first or edit manually.",
105            path.display()
106        )));
107    }
108
109    crate::profile_cmd::write_package_setting(&path, package.name())?;
110
111    if !args.global {
112        crate::trust::TrustGuard::for_new_file(&path).commit();
113    }
114
115    eprintln!(
116        "[rippy] Created {} with package \"{}\"\n  \
117         \"{}\"\n  \
118         Run `rippy profile show {}` for details, or edit {} to customize.",
119        path.display(),
120        package.name(),
121        package.tagline(),
122        package.name(),
123        path.display(),
124    );
125    Ok(ExitCode::SUCCESS)
126}
127
128/// Determine which package to use: from `--package` flag, interactive prompt,
129/// or default to `develop` when stdin is not a terminal.
130fn resolve_init_package(args: &InitArgs) -> Result<Package, RippyError> {
131    if let Some(name) = &args.package {
132        let home = config::home_dir();
133        return Package::resolve(name, home.as_deref());
134    }
135
136    if is_interactive() {
137        return prompt_package_selection();
138    }
139
140    // Non-interactive: default to develop.
141    Ok(Package::Develop)
142}
143
144fn is_interactive() -> bool {
145    use std::io::IsTerminal;
146    std::io::stdin().is_terminal()
147}
148
149fn prompt_package_selection() -> Result<Package, RippyError> {
150    let packages = Package::all();
151    let default_idx = packages
152        .iter()
153        .position(|p| *p == Package::Develop)
154        .unwrap_or(0);
155
156    eprintln!("\nWhich package fits your workflow?\n");
157    for (i, pkg) in packages.iter().enumerate() {
158        let recommended = if i == default_idx {
159            "  (recommended)"
160        } else {
161            ""
162        };
163        eprintln!(
164            "  [{}] {:<12}[{}]  {}{recommended}",
165            i + 1,
166            pkg.name(),
167            pkg.shield(),
168            pkg.tagline(),
169        );
170    }
171    eprint!(
172        "\nSelect [1-{}] (default: {}): ",
173        packages.len(),
174        default_idx + 1
175    );
176    let _ = std::io::stderr().flush();
177
178    let mut input = String::new();
179    if std::io::stdin().read_line(&mut input).is_err() {
180        return Ok(packages[default_idx].clone());
181    }
182
183    let trimmed = input.trim();
184    if trimmed.is_empty() {
185        return Ok(packages[default_idx].clone());
186    }
187
188    // Try as a 1-based index first, then as a package name.
189    if let Ok(n) = trimmed.parse::<usize>()
190        && n >= 1
191        && n <= packages.len()
192    {
193        return Ok(packages[n - 1].clone());
194    }
195
196    Package::parse(trimmed).map_err(RippyError::Setup)
197}
198
199#[cfg(test)]
200#[allow(clippy::unwrap_used)]
201mod tests {
202    use super::*;
203    use crate::config::Config;
204    use crate::verdict::Decision;
205
206    #[test]
207    fn stdlib_parses_without_error() {
208        let directives = stdlib_directives().unwrap();
209        assert!(!directives.is_empty());
210    }
211
212    #[test]
213    fn stdlib_cargo_safe_subcommands() {
214        let config = Config::from_directives(stdlib_directives().unwrap());
215        let v = config.match_command("cargo test --release", None);
216        assert!(v.is_some());
217        assert_eq!(v.unwrap().decision, Decision::Allow);
218    }
219
220    #[test]
221    fn stdlib_cargo_ask_subcommands() {
222        let config = Config::from_directives(stdlib_directives().unwrap());
223        let v = config.match_command("cargo run", None);
224        assert!(v.is_some());
225        assert_eq!(v.unwrap().decision, Decision::Ask);
226    }
227
228    #[test]
229    fn stdlib_cargo_unknown_defaults_to_ask() {
230        let config = Config::from_directives(stdlib_directives().unwrap());
231        let v = config.match_command("cargo some-unknown-subcommand", None);
232        assert!(v.is_some());
233        assert_eq!(v.unwrap().decision, Decision::Ask);
234    }
235
236    #[test]
237    fn stdlib_file_ops_ask() {
238        let config = Config::from_directives(stdlib_directives().unwrap());
239        for cmd in &["rm -rf /tmp/test", "mv a b", "chmod 755 file"] {
240            let v = config.match_command(cmd, None);
241            assert!(v.is_some(), "expected match for {cmd}");
242            assert_eq!(v.unwrap().decision, Decision::Ask, "expected ask for {cmd}");
243        }
244    }
245
246    #[test]
247    fn stdlib_dangerous_commands_ask() {
248        let config = Config::from_directives(stdlib_directives().unwrap());
249        for cmd in &["sudo apt install foo", "ssh user@host", "eval echo hi"] {
250            let v = config.match_command(cmd, None);
251            assert!(v.is_some(), "expected match for {cmd}");
252            assert_eq!(v.unwrap().decision, Decision::Ask, "expected ask for {cmd}");
253        }
254    }
255
256    #[test]
257    fn stdlib_toml_not_empty() {
258        let toml = stdlib_toml();
259        assert!(toml.contains("[[rules]]"));
260        assert!(toml.contains("cargo"));
261    }
262
263    #[test]
264    fn init_refuses_existing_file() {
265        let dir = tempfile::TempDir::new().unwrap();
266        let path = dir.path().join(".rippy.toml");
267        std::fs::write(&path, "existing").unwrap();
268
269        let original = std::env::current_dir().unwrap();
270        std::env::set_current_dir(dir.path()).unwrap();
271        let result = run_init(&InitArgs {
272            global: false,
273            stdout: false,
274            package: Some("develop".into()),
275        });
276        std::env::set_current_dir(original).unwrap();
277
278        assert!(result.is_err());
279    }
280}