lean_ctx/core/contextops/
mod.rs1pub 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}