Skip to main content

lean_ctx/core/contextops/
mod.rs

1pub mod config;
2pub mod drift;
3pub mod lint;
4pub mod sync;
5
6pub use config::RulesConfig;
7pub use drift::{DriftReport, DriftStatus};
8pub use lint::{LintSeverity, LintWarning};
9pub use sync::SyncReport;
10
11use std::path::Path;
12
13pub struct ContextOps {
14    pub home: std::path::PathBuf,
15    pub project_root: std::path::PathBuf,
16}
17
18impl ContextOps {
19    pub fn new(home: &Path, project_root: &Path) -> Self {
20        Self {
21            home: home.to_path_buf(),
22            project_root: project_root.to_path_buf(),
23        }
24    }
25
26    /// Drift between each agent's on-disk rules block and the canonical source.
27    ///
28    /// Infallible: drift is computed against `rules_canonical` (always present in
29    /// the binary), **not** against `.lean-ctx/rules.toml`, so `rules diff` needs
30    /// no prior `rules init` and can never fail on a missing config (#548).
31    pub fn detect_drift(&self) -> Vec<DriftReport> {
32        drift::detect_drift(&self.home)
33    }
34
35    pub fn sync_all(&self) -> SyncReport {
36        sync::sync_all(&self.home)
37    }
38
39    pub fn sync_agent(&self, agent: &str) -> SyncReport {
40        sync::sync_agent(&self.home, agent)
41    }
42
43    pub fn lint(&self) -> Result<Vec<LintWarning>, String> {
44        let config = RulesConfig::load(&self.project_root)?;
45        Ok(lint::lint(&config, &self.home))
46    }
47
48    pub fn status(&self) -> Vec<crate::rules_inject::RulesTargetStatus> {
49        crate::rules_inject::collect_rules_status(&self.home)
50    }
51
52    pub fn init(&self) -> Result<RulesConfig, String> {
53        RulesConfig::init_from_existing(&self.project_root, &self.home)
54    }
55
56    pub fn has_config(&self) -> bool {
57        RulesConfig::config_path(&self.project_root).exists()
58    }
59}
60
61pub fn format_status(statuses: &[crate::rules_inject::RulesTargetStatus]) -> String {
62    let mut lines = Vec::new();
63    lines.push("Agent Rules Status:".to_string());
64    lines.push(String::new());
65
66    for s in statuses {
67        let icon = match s.state.as_str() {
68            "up_to_date" => "✓",
69            "outdated" => "⚠",
70            "missing" => "✗",
71            "not_detected" => "·",
72            _ => "?",
73        };
74        let detected = if s.detected { "" } else { " (not installed)" };
75        lines.push(format!("  [{icon}] {}{detected} — {}", s.name, s.state));
76    }
77
78    lines.join("\n")
79}
80
81pub fn format_drift(reports: &[DriftReport]) -> String {
82    let mut lines = Vec::new();
83    lines.push("Drift Report:".to_string());
84    lines.push(String::new());
85
86    for r in reports {
87        if r.status == DriftStatus::NotDetected {
88            continue;
89        }
90        lines.push(format!("  [{}] {} ({})", r.status, r.target, r.path));
91        if let Some(diff) = &r.diff {
92            for dl in diff.lines().take(10) {
93                lines.push(format!("    {dl}"));
94            }
95            let total = diff.lines().count();
96            if total > 10 {
97                lines.push(format!("    ... ({} more lines)", total - 10));
98            }
99        }
100    }
101
102    lines.join("\n")
103}
104
105pub fn format_lint(warnings: &[LintWarning]) -> String {
106    if warnings.is_empty() {
107        return "No lint issues found.".to_string();
108    }
109
110    let mut lines = Vec::new();
111    lines.push(format!("Lint Results ({} issues):", warnings.len()));
112    lines.push(String::new());
113
114    for w in warnings {
115        let target = w
116            .target
117            .as_deref()
118            .map(|t| format!(" [{t}]"))
119            .unwrap_or_default();
120        lines.push(format!(
121            "  [{severity}] {code}{target}: {msg}",
122            severity = w.severity,
123            code = w.code,
124            msg = w.message,
125        ));
126    }
127
128    lines.join("\n")
129}
130
131pub fn format_sync(report: &SyncReport) -> String {
132    let mut lines = Vec::new();
133    lines.push("Sync Report:".to_string());
134    lines.push(String::new());
135
136    if !report.synced.is_empty() {
137        lines.push(format!("  Synced: {}", report.synced.join(", ")));
138    }
139    if !report.skipped.is_empty() {
140        lines.push(format!("  Already in sync: {}", report.skipped.join(", ")));
141    }
142    if !report.errors.is_empty() {
143        lines.push(format!("  Errors: {}", report.errors.join(", ")));
144    }
145    if report.synced.is_empty() && report.skipped.is_empty() && report.errors.is_empty() {
146        lines.push("  No targets found.".to_string());
147    }
148
149    lines.join("\n")
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn context_ops_has_config_false() {
158        let ops = ContextOps::new(
159            Path::new("/tmp/fake"),
160            Path::new("/tmp/nonexistent_contextops"),
161        );
162        assert!(!ops.has_config());
163    }
164
165    // #548 criterion 2: `rules diff` must not require `.lean-ctx/rules.toml`.
166    // `detect_drift` is now infallible and compares against the canonical rule
167    // source, so a project with no rules.toml produces a (possibly empty) drift
168    // report instead of the old "No rules config found" hard error. Serialized
169    // against other tests that read/write the global `CLAUDE_CONFIG_DIR`.
170    #[test]
171    #[serial_test::serial(claude_config_dir)]
172    fn detect_drift_without_rules_toml_does_not_require_init() {
173        let home = tempfile::tempdir().unwrap();
174        let project = tempfile::tempdir().unwrap();
175        let claude_dir = home.path().join(".claude").to_string_lossy().into_owned();
176        let _g = crate::setup::EnvVarGuard::set("CLAUDE_CONFIG_DIR", &claude_dir);
177
178        let ops = ContextOps::new(home.path(), project.path());
179        assert!(
180            !ops.has_config(),
181            "sandbox project must have no rules.toml for this regression test"
182        );
183
184        // The pre-#548 code path would have errored here on the missing config.
185        // Now it returns well-formed reports straight from the canonical source.
186        let reports = ops.detect_drift();
187        for r in &reports {
188            assert!(!r.target.is_empty(), "drift report missing a target name");
189        }
190    }
191
192    #[test]
193    fn format_status_output() {
194        let statuses = vec![crate::rules_inject::RulesTargetStatus {
195            name: "TestAgent".to_string(),
196            detected: true,
197            path: "/tmp/test".to_string(),
198            state: "up_to_date".to_string(),
199            note: None,
200        }];
201        let output = format_status(&statuses);
202        assert!(output.contains("✓"));
203        assert!(output.contains("TestAgent"));
204    }
205
206    #[test]
207    fn format_drift_skips_not_detected() {
208        let reports = vec![DriftReport {
209            target: "Ghost".to_string(),
210            path: "/tmp/ghost".to_string(),
211            status: DriftStatus::NotDetected,
212            diff: None,
213        }];
214        let output = format_drift(&reports);
215        assert!(!output.contains("Ghost"));
216    }
217
218    #[test]
219    fn format_lint_empty() {
220        let output = format_lint(&[]);
221        assert_eq!(output, "No lint issues found.");
222    }
223
224    #[test]
225    fn format_lint_with_warnings() {
226        let warnings = vec![LintWarning {
227            severity: LintSeverity::Warning,
228            code: "TEST".to_string(),
229            message: "test warning".to_string(),
230            target: Some("cursor".to_string()),
231        }];
232        let output = format_lint(&warnings);
233        assert!(output.contains("[WARNING]"));
234        assert!(output.contains("[cursor]"));
235    }
236
237    #[test]
238    fn format_sync_empty() {
239        let report = SyncReport {
240            synced: vec![],
241            skipped: vec![],
242            errors: vec![],
243        };
244        let output = format_sync(&report);
245        assert!(output.contains("No targets found"));
246    }
247
248    #[test]
249    fn format_sync_with_results() {
250        let report = SyncReport {
251            synced: vec!["Cursor".to_string()],
252            skipped: vec!["Claude Code".to_string()],
253            errors: vec![],
254        };
255        let output = format_sync(&report);
256        assert!(output.contains("Cursor"));
257        assert!(output.contains("Claude Code"));
258    }
259}