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) -> 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 #[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 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}