Skip to main content

rippy_cli/config/
loader.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::RippyError;
4use crate::verdict::Decision;
5
6use super::Config;
7use super::parser::{parse_action_word, parse_rule};
8use super::types::{ConfigDirective, Rule};
9
10pub(super) fn apply_setting(config: &mut Config, key: &str, value: &str) {
11    match key {
12        "default" => config.default_action = parse_action_word(value),
13        "log" => config.log_file = Some(PathBuf::from(value)),
14        "log-full" => config.log_full = true,
15        "tracking" => {
16            config.tracking_db = Some(if value == "on" || value.is_empty() {
17                home_dir().map_or_else(
18                    || PathBuf::from(".rippy/tracking.db"),
19                    |h| h.join(".rippy/tracking.db"),
20                )
21            } else {
22                PathBuf::from(value)
23            });
24        }
25        "trust-project-configs" => {
26            config.trust_project_configs = value != "off" && value != "false";
27        }
28        "self-protect" => {
29            config.self_protect = value != "off";
30        }
31        // "package" is handled during load_with_home() pre-scan, not here.
32        _ => {}
33    }
34}
35
36/// Detect dangerous settings in project config directives.
37pub(super) fn detect_dangerous_setting(key: &str, value: &str, notes: &mut Vec<String>) {
38    if key == "default" && value == "allow" {
39        notes.push("sets default action to allow (all unknown commands auto-approved)".to_string());
40    }
41    if key == "self-protect" && value == "off" {
42        notes.push("disables self-protection (AI tools can modify rippy config)".to_string());
43    }
44}
45
46/// Detect overly broad allow rules in project config directives.
47pub(super) fn detect_broad_allow(rule: &Rule, notes: &mut Vec<String>) {
48    if rule.decision != Decision::Allow {
49        return;
50    }
51    let raw = rule.pattern.raw();
52    if raw == "*" || raw == "**" || raw == "*|" {
53        notes.push(format!("allows all commands with pattern \"{raw}\""));
54    }
55}
56
57/// Pre-format the weakening notes into a suffix string for verdict annotation.
58///
59/// Returns an empty string if there are no notes.
60pub(super) fn build_weakening_suffix(notes: &[String]) -> String {
61    if notes.is_empty() {
62        return String::new();
63    }
64    let mut suffix = String::from(" | NOTE: project config ");
65    for (i, note) in notes.iter().enumerate() {
66        if i > 0 {
67            suffix.push_str(", ");
68        }
69        suffix.push_str(note);
70    }
71    suffix
72}
73
74// ---------------------------------------------------------------------------
75// File loading
76// ---------------------------------------------------------------------------
77
78/// Load the first file that exists from a list of candidates.
79pub(super) fn load_first_existing(
80    paths: &[PathBuf],
81    directives: &mut Vec<ConfigDirective>,
82) -> Result<(), RippyError> {
83    for path in paths {
84        if path.is_file() {
85            return load_file(path, directives);
86        }
87    }
88    Ok(())
89}
90
91/// Parse a single config file and append directives to the list.
92///
93/// # Errors
94///
95/// Returns `RippyError::Config` if the file cannot be read or contains invalid syntax.
96pub fn load_file(path: &Path, directives: &mut Vec<ConfigDirective>) -> Result<(), RippyError> {
97    let content = std::fs::read_to_string(path).map_err(|e| RippyError::Config {
98        path: path.to_owned(),
99        line: 0,
100        message: format!("could not read: {e}"),
101    })?;
102
103    load_file_from_content(&content, path, directives)
104}
105
106/// Parse config content (already read from disk) and append directives.
107pub(super) fn load_file_from_content(
108    content: &str,
109    path: &Path,
110    directives: &mut Vec<ConfigDirective>,
111) -> Result<(), RippyError> {
112    if path.extension().is_some_and(|ext| ext == "toml") {
113        let parsed = crate::toml_config::parse_toml_config(content, path)?;
114        directives.extend(parsed);
115        return Ok(());
116    }
117
118    for (line_num, line) in content.lines().enumerate() {
119        let line = line.trim();
120        if line.is_empty() || line.starts_with('#') {
121            continue;
122        }
123        let directive = parse_rule(line).map_err(|msg| RippyError::Config {
124            path: path.to_owned(),
125            line: line_num + 1,
126            message: msg,
127        })?;
128        directives.push(directive);
129    }
130
131    Ok(())
132}
133
134/// Check whether already-loaded directives contain `trust-project-configs = on/true`.
135pub(super) fn has_trust_setting(directives: &[ConfigDirective]) -> bool {
136    directives.iter().rev().any(|d| {
137        matches!(
138            d,
139            ConfigDirective::Set { key, value }
140            if key == "trust-project-configs"
141                && value != "off"
142                && value != "false"
143        )
144    })
145}
146
147/// Load a project config file only if it is trusted.
148///
149/// If `trust_all` is true (from `trust-project-configs = on` in global config),
150/// the file is loaded unconditionally. Otherwise, the trust database is consulted
151/// and untrusted/modified configs are skipped with a stderr warning.
152pub(super) fn load_project_config_if_trusted(
153    path: &Path,
154    trust_all: bool,
155    directives: &mut Vec<ConfigDirective>,
156) -> Result<(), RippyError> {
157    let content = std::fs::read_to_string(path).map_err(|e| RippyError::Config {
158        path: path.to_owned(),
159        line: 0,
160        message: format!("could not read: {e}"),
161    })?;
162
163    if trust_all {
164        return load_file_from_content(&content, path, directives);
165    }
166
167    let db = crate::trust::TrustDb::load();
168    match db.check(path, &content) {
169        crate::trust::TrustStatus::Trusted => load_file_from_content(&content, path, directives),
170        crate::trust::TrustStatus::Untrusted => {
171            eprintln!(
172                "[rippy] untrusted project config: {} — run `rippy trust` to review and enable",
173                path.display()
174            );
175            Ok(())
176        }
177        crate::trust::TrustStatus::Modified { .. } => {
178            eprintln!(
179                "[rippy] project config modified since last trust: {} — \
180                 run `rippy trust` to re-approve",
181                path.display()
182            );
183            Ok(())
184        }
185    }
186}
187
188pub fn home_dir() -> Option<PathBuf> {
189    std::env::var_os("HOME").map(PathBuf::from)
190}
191
192/// Pre-scan a config file to extract the `package` setting, if present.
193///
194/// This is a lightweight read that avoids loading full directives — it only
195/// parses enough to find `settings.package` (TOML) or `set package <value>`
196/// (line-based).
197pub(super) fn extract_package_setting(path: &Path) -> Option<String> {
198    let content = std::fs::read_to_string(path).ok()?;
199    if path.extension().is_some_and(|ext| ext == "toml") {
200        let config: crate::toml_config::TomlConfig = toml::from_str(&content).ok()?;
201        config.settings?.package
202    } else {
203        // Line-based format: look for `set package <value>`
204        for line in content.lines() {
205            let line = line.trim();
206            if let Some(rest) = line.strip_prefix("set package ") {
207                let value = rest.trim().trim_matches('"');
208                if !value.is_empty() {
209                    return Some(value.to_string());
210                }
211            }
212        }
213        None
214    }
215}