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    pub fn detect_drift(&self) -> Result<Vec<DriftReport>, String> {
27        let config = RulesConfig::load(&self.project_root)?;
28        Ok(drift::detect_drift(&self.home, &config))
29    }
30
31    pub fn sync_all(&self) -> SyncReport {
32        sync::sync_all(&self.home)
33    }
34
35    pub fn sync_agent(&self, agent: &str) -> SyncReport {
36        sync::sync_agent(&self.home, agent)
37    }
38
39    pub fn lint(&self) -> Result<Vec<LintWarning>, String> {
40        let config = RulesConfig::load(&self.project_root)?;
41        Ok(lint::lint(&config, &self.home))
42    }
43
44    pub fn status(&self) -> Vec<crate::rules_inject::RulesTargetStatus> {
45        crate::rules_inject::collect_rules_status(&self.home)
46    }
47
48    pub fn init(&self) -> Result<RulesConfig, String> {
49        RulesConfig::init_from_existing(&self.project_root, &self.home)
50    }
51
52    pub fn has_config(&self) -> bool {
53        RulesConfig::config_path(&self.project_root).exists()
54    }
55}
56
57pub fn format_status(statuses: &[crate::rules_inject::RulesTargetStatus]) -> String {
58    let mut lines = Vec::new();
59    lines.push("Agent Rules Status:".to_string());
60    lines.push(String::new());
61
62    for s in statuses {
63        let icon = match s.state.as_str() {
64            "up_to_date" => "✓",
65            "outdated" => "⚠",
66            "missing" => "✗",
67            "not_detected" => "·",
68            _ => "?",
69        };
70        let detected = if s.detected { "" } else { " (not installed)" };
71        lines.push(format!("  [{icon}] {}{detected} — {}", s.name, s.state));
72    }
73
74    lines.join("\n")
75}
76
77pub fn format_drift(reports: &[DriftReport]) -> String {
78    let mut lines = Vec::new();
79    lines.push("Drift Report:".to_string());
80    lines.push(String::new());
81
82    for r in reports {
83        if r.status == DriftStatus::NotDetected {
84            continue;
85        }
86        lines.push(format!("  [{}] {} ({})", r.status, r.target, r.path));
87        if let Some(diff) = &r.diff {
88            for dl in diff.lines().take(10) {
89                lines.push(format!("    {dl}"));
90            }
91            let total = diff.lines().count();
92            if total > 10 {
93                lines.push(format!("    ... ({} more lines)", total - 10));
94            }
95        }
96    }
97
98    lines.join("\n")
99}
100
101pub fn format_lint(warnings: &[LintWarning]) -> String {
102    if warnings.is_empty() {
103        return "No lint issues found.".to_string();
104    }
105
106    let mut lines = Vec::new();
107    lines.push(format!("Lint Results ({} issues):", warnings.len()));
108    lines.push(String::new());
109
110    for w in warnings {
111        let target = w
112            .target
113            .as_deref()
114            .map(|t| format!(" [{t}]"))
115            .unwrap_or_default();
116        lines.push(format!(
117            "  [{severity}] {code}{target}: {msg}",
118            severity = w.severity,
119            code = w.code,
120            msg = w.message,
121        ));
122    }
123
124    lines.join("\n")
125}
126
127pub fn format_sync(report: &SyncReport) -> String {
128    let mut lines = Vec::new();
129    lines.push("Sync Report:".to_string());
130    lines.push(String::new());
131
132    if !report.synced.is_empty() {
133        lines.push(format!("  Synced: {}", report.synced.join(", ")));
134    }
135    if !report.skipped.is_empty() {
136        lines.push(format!("  Already in sync: {}", report.skipped.join(", ")));
137    }
138    if !report.errors.is_empty() {
139        lines.push(format!("  Errors: {}", report.errors.join(", ")));
140    }
141    if report.synced.is_empty() && report.skipped.is_empty() && report.errors.is_empty() {
142        lines.push("  No targets found.".to_string());
143    }
144
145    lines.join("\n")
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn context_ops_has_config_false() {
154        let ops = ContextOps::new(
155            Path::new("/tmp/fake"),
156            Path::new("/tmp/nonexistent_contextops"),
157        );
158        assert!(!ops.has_config());
159    }
160
161    #[test]
162    fn format_status_output() {
163        let statuses = vec![crate::rules_inject::RulesTargetStatus {
164            name: "TestAgent".to_string(),
165            detected: true,
166            path: "/tmp/test".to_string(),
167            state: "up_to_date".to_string(),
168            note: None,
169        }];
170        let output = format_status(&statuses);
171        assert!(output.contains("✓"));
172        assert!(output.contains("TestAgent"));
173    }
174
175    #[test]
176    fn format_drift_skips_not_detected() {
177        let reports = vec![DriftReport {
178            target: "Ghost".to_string(),
179            path: "/tmp/ghost".to_string(),
180            status: DriftStatus::NotDetected,
181            diff: None,
182        }];
183        let output = format_drift(&reports);
184        assert!(!output.contains("Ghost"));
185    }
186
187    #[test]
188    fn format_lint_empty() {
189        let output = format_lint(&[]);
190        assert_eq!(output, "No lint issues found.");
191    }
192
193    #[test]
194    fn format_lint_with_warnings() {
195        let warnings = vec![LintWarning {
196            severity: LintSeverity::Warning,
197            code: "TEST".to_string(),
198            message: "test warning".to_string(),
199            target: Some("cursor".to_string()),
200        }];
201        let output = format_lint(&warnings);
202        assert!(output.contains("[WARNING]"));
203        assert!(output.contains("[cursor]"));
204    }
205
206    #[test]
207    fn format_sync_empty() {
208        let report = SyncReport {
209            synced: vec![],
210            skipped: vec![],
211            errors: vec![],
212        };
213        let output = format_sync(&report);
214        assert!(output.contains("No targets found"));
215    }
216
217    #[test]
218    fn format_sync_with_results() {
219        let report = SyncReport {
220            synced: vec!["Cursor".to_string()],
221            skipped: vec!["Claude Code".to_string()],
222            errors: vec![],
223        };
224        let output = format_sync(&report);
225        assert!(output.contains("Cursor"));
226        assert!(output.contains("Claude Code"));
227    }
228}