1use std::path::PathBuf;
5
6#[cfg(target_os = "macos")]
7const LABEL: &str = "com.leanctx.autoupdate";
8
9#[derive(Debug, Clone)]
10pub struct ScheduleInfo {
11 pub enabled: bool,
12 pub mechanism: String,
13 pub interval_hours: u64,
14 pub scheduler_path: Option<PathBuf>,
15 pub last_check: Option<String>,
16}
17
18impl std::fmt::Display for ScheduleInfo {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 if self.enabled {
21 write!(
22 f,
23 "Auto-update: enabled ({}, every {}h)",
24 self.mechanism, self.interval_hours
25 )?;
26 if let Some(ref path) = self.scheduler_path {
27 write!(f, "\n Scheduler: {}", path.display())?;
28 }
29 if let Some(ref last) = self.last_check {
30 write!(f, "\n Last check: {last}")?;
31 }
32 } else {
33 write!(f, "Auto-update: disabled")?;
34 }
35 Ok(())
36 }
37}
38
39pub fn install_schedule(interval_hours: u64) -> Result<ScheduleInfo, String> {
40 let binary = std::env::current_exe().map_err(|e| format!("Cannot locate binary: {e}"))?;
41
42 #[cfg(target_os = "macos")]
43 return install_macos_launchagent(&binary, interval_hours * 3600, interval_hours);
44
45 #[cfg(target_os = "linux")]
46 return install_linux_scheduler(&binary, interval_hours);
47
48 #[cfg(target_os = "windows")]
49 return install_windows_task(&binary, interval_hours);
50
51 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
52 {
53 let _ = binary;
54 Err("Auto-update scheduling not supported on this platform".to_string())
55 }
56}
57
58pub fn remove_schedule() -> Result<(), String> {
59 #[cfg(target_os = "macos")]
60 return remove_macos_launchagent();
61
62 #[cfg(target_os = "linux")]
63 return remove_linux_scheduler();
64
65 #[cfg(target_os = "windows")]
66 return remove_windows_task();
67
68 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
69 Ok(())
70}
71
72pub fn schedule_status() -> ScheduleInfo {
73 #[cfg(target_os = "macos")]
74 return macos_status();
75
76 #[cfg(target_os = "linux")]
77 return linux_status();
78
79 #[cfg(target_os = "windows")]
80 return windows_status();
81
82 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
83 ScheduleInfo {
84 enabled: false,
85 mechanism: "unsupported".into(),
86 interval_hours: 0,
87 scheduler_path: None,
88 last_check: None,
89 }
90}
91
92#[cfg(target_os = "macos")]
95fn plist_path() -> PathBuf {
96 dirs::home_dir()
97 .unwrap_or_else(|| PathBuf::from("/tmp"))
98 .join("Library/LaunchAgents")
99 .join(format!("{LABEL}.plist"))
100}
101
102#[cfg(target_os = "macos")]
103fn install_macos_launchagent(
104 binary: &std::path::Path,
105 interval_secs: u64,
106 interval_hours: u64,
107) -> Result<ScheduleInfo, String> {
108 let path = plist_path();
109 if let Some(dir) = path.parent() {
110 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
111 }
112
113 let home = dirs::home_dir().unwrap_or_default();
114 let log_dir = home.join(".lean-ctx");
115 let _ = std::fs::create_dir_all(&log_dir);
116
117 let binary_str = binary.to_string_lossy();
118 let stdout_log = log_dir.join("autoupdate-stdout.log");
119 let stderr_log = log_dir.join("autoupdate-stderr.log");
120
121 let plist = format!(
122 r#"<?xml version="1.0" encoding="UTF-8"?>
123<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
124<plist version="1.0">
125<dict>
126 <key>Label</key>
127 <string>{LABEL}</string>
128 <key>ProgramArguments</key>
129 <array>
130 <string>{binary_str}</string>
131 <string>update</string>
132 <string>--quiet</string>
133 </array>
134 <key>StartInterval</key>
135 <integer>{interval_secs}</integer>
136 <key>RunAtLoad</key>
137 <false/>
138 <key>StandardOutPath</key>
139 <string>{}</string>
140 <key>StandardErrorPath</key>
141 <string>{}</string>
142</dict>
143</plist>"#,
144 stdout_log.display(),
145 stderr_log.display()
146 );
147
148 let _ = std::process::Command::new("launchctl")
149 .args(["unload", &path.to_string_lossy()])
150 .output();
151
152 std::fs::write(&path, plist).map_err(|e| format!("Failed to write plist: {e}"))?;
153
154 let out = std::process::Command::new("launchctl")
155 .args(["load", &path.to_string_lossy()])
156 .output()
157 .map_err(|e| format!("Failed to load LaunchAgent: {e}"))?;
158
159 if !out.status.success() {
160 let stderr = String::from_utf8_lossy(&out.stderr);
161 return Err(format!("launchctl load failed: {stderr}"));
162 }
163
164 Ok(ScheduleInfo {
165 enabled: true,
166 mechanism: "LaunchAgent".into(),
167 interval_hours,
168 scheduler_path: Some(path),
169 last_check: None,
170 })
171}
172
173#[cfg(target_os = "macos")]
174fn remove_macos_launchagent() -> Result<(), String> {
175 let path = plist_path();
176 if path.exists() {
177 let _ = std::process::Command::new("launchctl")
178 .args(["unload", &path.to_string_lossy()])
179 .output();
180 std::fs::remove_file(&path).map_err(|e| format!("Failed to remove plist: {e}"))?;
181 }
182 Ok(())
183}
184
185#[cfg(target_os = "macos")]
186fn macos_status() -> ScheduleInfo {
187 let path = plist_path();
188 let enabled = path.exists();
189 let interval_hours = if enabled {
190 std::fs::read_to_string(&path)
191 .ok()
192 .and_then(|content| {
193 let idx = content.find("<key>StartInterval</key>")?;
194 let after = &content[idx..];
195 let int_start = after.find("<integer>")? + 9;
196 let int_end = after.find("</integer>")?;
197 after[int_start..int_end].parse::<u64>().ok()
198 })
199 .map_or(6, |s| s / 3600)
200 } else {
201 0
202 };
203 ScheduleInfo {
204 enabled,
205 mechanism: "LaunchAgent".into(),
206 interval_hours,
207 scheduler_path: Some(path),
208 last_check: read_last_check_time(),
209 }
210}
211
212#[cfg(target_os = "linux")]
215fn has_systemd() -> bool {
216 std::path::Path::new("/run/systemd/system").exists()
217}
218
219#[cfg(target_os = "linux")]
220fn systemd_dir() -> PathBuf {
221 dirs::home_dir()
222 .unwrap_or_else(|| PathBuf::from("/tmp"))
223 .join(".config/systemd/user")
224}
225
226#[cfg(target_os = "linux")]
227fn install_linux_scheduler(
228 binary: &std::path::Path,
229 interval_hours: u64,
230) -> Result<ScheduleInfo, String> {
231 if has_systemd() {
232 install_linux_systemd(binary, interval_hours)
233 } else {
234 install_linux_cron(binary, interval_hours)
235 }
236}
237
238#[cfg(target_os = "linux")]
239fn install_linux_systemd(
240 binary: &std::path::Path,
241 interval_hours: u64,
242) -> Result<ScheduleInfo, String> {
243 let dir = systemd_dir();
244 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
245
246 let binary_str = binary.to_string_lossy();
247
248 let service = format!(
249 "[Unit]\nDescription=lean-ctx auto-updater\n\n[Service]\nType=oneshot\nExecStart={binary_str} update --quiet\n"
250 );
251 let timer = format!(
252 "[Unit]\nDescription=lean-ctx auto-update timer\n\n[Timer]\nOnBootSec=1h\nOnUnitActiveSec={interval_hours}h\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n"
253 );
254
255 std::fs::write(dir.join("lean-ctx-autoupdate.service"), service).map_err(|e| e.to_string())?;
256 let timer_path = dir.join("lean-ctx-autoupdate.timer");
257 std::fs::write(&timer_path, timer).map_err(|e| e.to_string())?;
258
259 let _ = std::process::Command::new("systemctl")
260 .args(["--user", "daemon-reload"])
261 .output();
262 let out = std::process::Command::new("systemctl")
263 .args(["--user", "enable", "--now", "lean-ctx-autoupdate.timer"])
264 .output()
265 .map_err(|e| e.to_string())?;
266
267 if !out.status.success() {
268 return Err(format!(
269 "systemctl enable failed: {}",
270 String::from_utf8_lossy(&out.stderr)
271 ));
272 }
273
274 Ok(ScheduleInfo {
275 enabled: true,
276 mechanism: "systemd timer".into(),
277 interval_hours,
278 scheduler_path: Some(timer_path),
279 last_check: None,
280 })
281}
282
283#[cfg(target_os = "linux")]
284fn install_linux_cron(
285 binary: &std::path::Path,
286 interval_hours: u64,
287) -> Result<ScheduleInfo, String> {
288 let cron_expr = if interval_hours <= 1 {
289 "0 * * * *".to_string()
290 } else if interval_hours >= 24 {
291 "0 4 * * *".to_string()
292 } else {
293 format!("0 */{interval_hours} * * *")
294 };
295
296 let entry = format!("{cron_expr} {} update --quiet", binary.to_string_lossy());
297
298 let existing = std::process::Command::new("crontab")
299 .arg("-l")
300 .output()
301 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
302 .unwrap_or_default();
303
304 let filtered: String = existing
305 .lines()
306 .filter(|l| !l.contains("lean-ctx") || !l.contains("update"))
307 .chain(std::iter::once(entry.as_str()))
308 .collect::<Vec<_>>()
309 .join("\n")
310 + "\n";
311
312 let mut child = std::process::Command::new("crontab")
313 .arg("-")
314 .stdin(std::process::Stdio::piped())
315 .spawn()
316 .map_err(|e| e.to_string())?;
317
318 use std::io::Write;
319 child
320 .stdin
321 .take()
322 .unwrap()
323 .write_all(filtered.as_bytes())
324 .map_err(|e| e.to_string())?;
325 child.wait().map_err(|e| e.to_string())?;
326
327 Ok(ScheduleInfo {
328 enabled: true,
329 mechanism: "cron".into(),
330 interval_hours,
331 scheduler_path: None,
332 last_check: None,
333 })
334}
335
336#[cfg(target_os = "linux")]
337#[allow(clippy::unnecessary_wraps)]
338fn remove_linux_scheduler() -> Result<(), String> {
339 let dir = systemd_dir();
340 let timer = dir.join("lean-ctx-autoupdate.timer");
341 let service = dir.join("lean-ctx-autoupdate.service");
342 if timer.exists() {
343 let _ = std::process::Command::new("systemctl")
344 .args(["--user", "disable", "--now", "lean-ctx-autoupdate.timer"])
345 .output();
346 let _ = std::fs::remove_file(&timer);
347 let _ = std::fs::remove_file(&service);
348 let _ = std::process::Command::new("systemctl")
349 .args(["--user", "daemon-reload"])
350 .output();
351 }
352
353 if let Ok(out) = std::process::Command::new("crontab").arg("-l").output() {
354 let existing = String::from_utf8_lossy(&out.stdout).to_string();
355 if existing.contains("lean-ctx") && existing.contains("update") {
356 let filtered: String = existing
357 .lines()
358 .filter(|l| !(l.contains("lean-ctx") && l.contains("update")))
359 .collect::<Vec<_>>()
360 .join("\n")
361 + "\n";
362 if let Ok(mut child) = std::process::Command::new("crontab")
363 .arg("-")
364 .stdin(std::process::Stdio::piped())
365 .spawn()
366 {
367 use std::io::Write;
368 if let Some(mut stdin) = child.stdin.take() {
369 let _ = stdin.write_all(filtered.as_bytes());
370 }
371 let _ = child.wait();
372 }
373 }
374 }
375 Ok(())
376}
377
378#[cfg(target_os = "linux")]
379fn linux_status() -> ScheduleInfo {
380 let timer = systemd_dir().join("lean-ctx-autoupdate.timer");
381 if timer.exists() {
382 return ScheduleInfo {
383 enabled: true,
384 mechanism: "systemd timer".into(),
385 interval_hours: 6,
386 scheduler_path: Some(timer),
387 last_check: read_last_check_time(),
388 };
389 }
390 if let Ok(out) = std::process::Command::new("crontab").arg("-l").output() {
391 let crontab = String::from_utf8_lossy(&out.stdout);
392 if crontab.contains("lean-ctx") && crontab.contains("update") {
393 return ScheduleInfo {
394 enabled: true,
395 mechanism: "cron".into(),
396 interval_hours: 6,
397 scheduler_path: None,
398 last_check: read_last_check_time(),
399 };
400 }
401 }
402 ScheduleInfo {
403 enabled: false,
404 mechanism: "none".into(),
405 interval_hours: 0,
406 scheduler_path: None,
407 last_check: None,
408 }
409}
410
411#[cfg(target_os = "windows")]
414fn install_windows_task(
415 binary: &std::path::Path,
416 interval_hours: u64,
417) -> Result<ScheduleInfo, String> {
418 let binary_str = binary.to_string_lossy();
419 let out = std::process::Command::new("schtasks")
420 .args([
421 "/Create",
422 "/F",
423 "/TN",
424 "lean-ctx autoupdate",
425 "/TR",
426 &format!("\"{binary_str}\" update --quiet"),
427 "/SC",
428 "HOURLY",
429 "/MO",
430 &interval_hours.to_string(),
431 "/RL",
432 "HIGHEST",
433 ])
434 .output()
435 .map_err(|e| e.to_string())?;
436
437 if !out.status.success() {
438 return Err(format!(
439 "schtasks failed: {}",
440 String::from_utf8_lossy(&out.stderr)
441 ));
442 }
443
444 Ok(ScheduleInfo {
445 enabled: true,
446 mechanism: "Task Scheduler".into(),
447 interval_hours,
448 scheduler_path: None,
449 last_check: None,
450 })
451}
452
453#[cfg(target_os = "windows")]
454fn remove_windows_task() -> Result<(), String> {
455 let _ = std::process::Command::new("schtasks")
456 .args(["/Delete", "/F", "/TN", "lean-ctx autoupdate"])
457 .output();
458 Ok(())
459}
460
461#[cfg(target_os = "windows")]
462fn windows_status() -> ScheduleInfo {
463 let out = std::process::Command::new("schtasks")
464 .args(["/Query", "/TN", "lean-ctx autoupdate", "/FO", "LIST"])
465 .output();
466
467 let enabled = out.as_ref().is_ok_and(|o| o.status.success());
468 ScheduleInfo {
469 enabled,
470 mechanism: "Task Scheduler".into(),
471 interval_hours: if enabled { 6 } else { 0 },
472 scheduler_path: None,
473 last_check: read_last_check_time(),
474 }
475}
476
477fn read_last_check_time() -> Option<String> {
480 let path = crate::core::data_dir::lean_ctx_data_dir()
481 .ok()?
482 .join("latest-version.json");
483 let content = std::fs::read_to_string(path).ok()?;
484 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
485 let ts = v["checked_at"].as_u64()?;
486 let dt = chrono::DateTime::from_timestamp(ts as i64, 0)?;
487 Some(dt.format("%Y-%m-%d %H:%M UTC").to_string())
488}
489
490pub fn has_user_decided() -> bool {
492 let Some(home) = dirs::home_dir() else {
493 return false;
494 };
495 let config_path = home.join(".lean-ctx").join("config.toml");
496 let content = std::fs::read_to_string(config_path).unwrap_or_default();
497 content.contains("auto_update")
498}
499
500pub fn set_auto_update(enabled: bool, notify_only: bool, interval_hours: u64) {
502 let Some(home) = dirs::home_dir() else {
503 return;
504 };
505 let config_dir = home.join(".lean-ctx");
506 let _ = std::fs::create_dir_all(&config_dir);
507 let config_path = config_dir.join("config.toml");
508 let mut content = std::fs::read_to_string(&config_path).unwrap_or_default();
509
510 if let Some(start) = content.find("[updates]") {
511 let section_end = content[start + 9..]
512 .find("\n[")
513 .map_or(content.len(), |i| start + 9 + i);
514 content = format!("{}{}", &content[..start], &content[section_end..]);
515 }
516
517 if !content.is_empty() && !content.ends_with('\n') {
518 content.push('\n');
519 }
520 content.push_str(&format!(
521 "\n[updates]\nauto_update = {enabled}\ncheck_interval_hours = {interval_hours}\nnotify_only = {notify_only}\n"
522 ));
523
524 let _ = std::fs::write(&config_path, content);
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn schedule_info_display_disabled() {
533 let info = ScheduleInfo {
534 enabled: false,
535 mechanism: "none".into(),
536 interval_hours: 0,
537 scheduler_path: None,
538 last_check: None,
539 };
540 assert!(info.to_string().contains("disabled"));
541 }
542
543 #[test]
544 fn schedule_info_display_enabled() {
545 let info = ScheduleInfo {
546 enabled: true,
547 mechanism: "LaunchAgent".into(),
548 interval_hours: 6,
549 scheduler_path: Some(PathBuf::from("/tmp/test.plist")),
550 last_check: Some("2026-05-17 10:00 UTC".into()),
551 };
552 let s = info.to_string();
553 assert!(s.contains("enabled"));
554 assert!(s.contains("LaunchAgent"));
555 assert!(s.contains("6h"));
556 }
557
558 #[test]
559 fn set_auto_update_writes_config() {
560 let tmp = tempfile::tempdir().unwrap();
561 let config_path = tmp.path().join("config.toml");
562 std::fs::write(&config_path, "buddy_enabled = true\n").unwrap();
563
564 let mut content = std::fs::read_to_string(&config_path).unwrap();
565 content.push_str(
566 "\n[updates]\nauto_update = true\ncheck_interval_hours = 12\nnotify_only = false\n",
567 );
568 std::fs::write(&config_path, &content).unwrap();
569
570 let result = std::fs::read_to_string(&config_path).unwrap();
571 assert!(result.contains("auto_update = true"));
572 assert!(result.contains("check_interval_hours = 12"));
573 assert!(result.contains("buddy_enabled = true"));
574 }
575
576 #[test]
577 fn has_user_decided_false_by_default() {
578 let _ = has_user_decided();
581 }
582}