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::path::PathBuf::from(super::portable_binary::resolve_portable_binary());
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 <string>--scheduled</string>
134 </array>
135 <key>StartInterval</key>
136 <integer>{interval_secs}</integer>
137 <key>RunAtLoad</key>
138 <false/>
139 <key>StandardOutPath</key>
140 <string>{}</string>
141 <key>StandardErrorPath</key>
142 <string>{}</string>
143</dict>
144</plist>"#,
145 stdout_log.display(),
146 stderr_log.display()
147 );
148
149 crate::core::launchd::bootout(LABEL, &path);
150
151 std::fs::write(&path, plist).map_err(|e| format!("Failed to write plist: {e}"))?;
152
153 if !crate::core::launchd::bootstrap(LABEL, &path) {
154 return Err("launchctl bootstrap failed; check: launchctl print gui/$(id -u)".into());
155 }
156
157 Ok(ScheduleInfo {
158 enabled: true,
159 mechanism: "LaunchAgent".into(),
160 interval_hours,
161 scheduler_path: Some(path),
162 last_check: None,
163 })
164}
165
166#[cfg(target_os = "macos")]
167fn remove_macos_launchagent() -> Result<(), String> {
168 let path = plist_path();
169 if path.exists() {
170 crate::core::launchd::bootout(LABEL, &path);
171 std::fs::remove_file(&path).map_err(|e| format!("Failed to remove plist: {e}"))?;
172 }
173 Ok(())
174}
175
176#[cfg(target_os = "macos")]
177fn macos_status() -> ScheduleInfo {
178 let path = plist_path();
179 let enabled = path.exists();
180 let interval_hours = if enabled {
181 std::fs::read_to_string(&path)
182 .ok()
183 .and_then(|content| {
184 let idx = content.find("<key>StartInterval</key>")?;
185 let after = &content[idx..];
186 let int_start = after.find("<integer>")? + 9;
187 let int_end = after.find("</integer>")?;
188 after[int_start..int_end].parse::<u64>().ok()
189 })
190 .map_or(6, |s| s / 3600)
191 } else {
192 0
193 };
194 ScheduleInfo {
195 enabled,
196 mechanism: "LaunchAgent".into(),
197 interval_hours,
198 scheduler_path: Some(path),
199 last_check: read_last_check_time(),
200 }
201}
202
203#[cfg(target_os = "linux")]
206fn has_systemd() -> bool {
207 std::path::Path::new("/run/systemd/system").exists()
208}
209
210#[cfg(target_os = "linux")]
211fn systemd_dir() -> PathBuf {
212 dirs::home_dir()
213 .unwrap_or_else(|| PathBuf::from("/tmp"))
214 .join(".config/systemd/user")
215}
216
217#[cfg(target_os = "linux")]
218fn install_linux_scheduler(
219 binary: &std::path::Path,
220 interval_hours: u64,
221) -> Result<ScheduleInfo, String> {
222 if has_systemd() {
223 install_linux_systemd(binary, interval_hours)
224 } else {
225 install_linux_cron(binary, interval_hours)
226 }
227}
228
229#[cfg(target_os = "linux")]
230fn install_linux_systemd(
231 binary: &std::path::Path,
232 interval_hours: u64,
233) -> Result<ScheduleInfo, String> {
234 let dir = systemd_dir();
235 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
236
237 let binary_str = binary.to_string_lossy();
238
239 let service = format!(
240 "[Unit]\nDescription=lean-ctx auto-updater\n\n[Service]\nType=oneshot\nExecStart={binary_str} update --quiet --scheduled\n"
241 );
242 let timer = format!(
243 "[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"
244 );
245
246 std::fs::write(dir.join("lean-ctx-autoupdate.service"), service).map_err(|e| e.to_string())?;
247 let timer_path = dir.join("lean-ctx-autoupdate.timer");
248 std::fs::write(&timer_path, timer).map_err(|e| e.to_string())?;
249
250 let _ = std::process::Command::new("systemctl")
251 .args(["--user", "daemon-reload"])
252 .output();
253 let out = std::process::Command::new("systemctl")
254 .args(["--user", "enable", "--now", "lean-ctx-autoupdate.timer"])
255 .output()
256 .map_err(|e| e.to_string())?;
257
258 if !out.status.success() {
259 return Err(format!(
260 "systemctl enable failed: {}",
261 String::from_utf8_lossy(&out.stderr)
262 ));
263 }
264
265 Ok(ScheduleInfo {
266 enabled: true,
267 mechanism: "systemd timer".into(),
268 interval_hours,
269 scheduler_path: Some(timer_path),
270 last_check: None,
271 })
272}
273
274#[cfg(target_os = "linux")]
275fn install_linux_cron(
276 binary: &std::path::Path,
277 interval_hours: u64,
278) -> Result<ScheduleInfo, String> {
279 let cron_expr = if interval_hours <= 1 {
280 "0 * * * *".to_string()
281 } else if interval_hours >= 24 {
282 "0 4 * * *".to_string()
283 } else {
284 format!("0 */{interval_hours} * * *")
285 };
286
287 let entry = format!(
288 "{cron_expr} {} update --quiet --scheduled",
289 binary.to_string_lossy()
290 );
291
292 let existing = std::process::Command::new("crontab")
293 .arg("-l")
294 .output()
295 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
296 .unwrap_or_default();
297
298 let filtered: String = existing
299 .lines()
300 .filter(|l| !l.contains("lean-ctx") || !l.contains("update"))
301 .chain(std::iter::once(entry.as_str()))
302 .collect::<Vec<_>>()
303 .join("\n")
304 + "\n";
305
306 let mut child = std::process::Command::new("crontab")
307 .arg("-")
308 .stdin(std::process::Stdio::piped())
309 .spawn()
310 .map_err(|e| e.to_string())?;
311
312 use std::io::Write;
313 child
314 .stdin
315 .take()
316 .ok_or_else(|| "failed to open crontab stdin".to_string())?
317 .write_all(filtered.as_bytes())
318 .map_err(|e| e.to_string())?;
319 child.wait().map_err(|e| e.to_string())?;
320
321 Ok(ScheduleInfo {
322 enabled: true,
323 mechanism: "cron".into(),
324 interval_hours,
325 scheduler_path: None,
326 last_check: None,
327 })
328}
329
330#[cfg(target_os = "linux")]
331#[allow(clippy::unnecessary_wraps)]
332fn remove_linux_scheduler() -> Result<(), String> {
333 let dir = systemd_dir();
334 let timer = dir.join("lean-ctx-autoupdate.timer");
335 let service = dir.join("lean-ctx-autoupdate.service");
336 if timer.exists() {
337 let _ = std::process::Command::new("systemctl")
338 .args(["--user", "disable", "--now", "lean-ctx-autoupdate.timer"])
339 .output();
340 let _ = std::fs::remove_file(&timer);
341 let _ = std::fs::remove_file(&service);
342 let _ = std::process::Command::new("systemctl")
343 .args(["--user", "daemon-reload"])
344 .output();
345 }
346
347 if let Ok(out) = std::process::Command::new("crontab").arg("-l").output() {
348 let existing = String::from_utf8_lossy(&out.stdout).to_string();
349 if existing.contains("lean-ctx") && existing.contains("update") {
350 let filtered: String = existing
351 .lines()
352 .filter(|l| !(l.contains("lean-ctx") && l.contains("update")))
353 .collect::<Vec<_>>()
354 .join("\n")
355 + "\n";
356 if let Ok(mut child) = std::process::Command::new("crontab")
357 .arg("-")
358 .stdin(std::process::Stdio::piped())
359 .spawn()
360 {
361 use std::io::Write;
362 if let Some(mut stdin) = child.stdin.take() {
363 let _ = stdin.write_all(filtered.as_bytes());
364 }
365 let _ = child.wait();
366 }
367 }
368 }
369 Ok(())
370}
371
372#[cfg(target_os = "linux")]
373fn linux_status() -> ScheduleInfo {
374 let timer = systemd_dir().join("lean-ctx-autoupdate.timer");
375 if timer.exists() {
376 return ScheduleInfo {
377 enabled: true,
378 mechanism: "systemd timer".into(),
379 interval_hours: 6,
380 scheduler_path: Some(timer),
381 last_check: read_last_check_time(),
382 };
383 }
384 if let Ok(out) = std::process::Command::new("crontab").arg("-l").output() {
385 let crontab = String::from_utf8_lossy(&out.stdout);
386 if crontab.contains("lean-ctx") && crontab.contains("update") {
387 return ScheduleInfo {
388 enabled: true,
389 mechanism: "cron".into(),
390 interval_hours: 6,
391 scheduler_path: None,
392 last_check: read_last_check_time(),
393 };
394 }
395 }
396 ScheduleInfo {
397 enabled: false,
398 mechanism: "none".into(),
399 interval_hours: 0,
400 scheduler_path: None,
401 last_check: None,
402 }
403}
404
405#[cfg(target_os = "windows")]
408fn install_windows_task(
409 binary: &std::path::Path,
410 interval_hours: u64,
411) -> Result<ScheduleInfo, String> {
412 let binary_str = binary.to_string_lossy();
413 let out = std::process::Command::new("schtasks")
414 .args([
415 "/Create",
416 "/F",
417 "/TN",
418 "lean-ctx autoupdate",
419 "/TR",
420 &format!("\"{binary_str}\" update --quiet --scheduled"),
421 "/SC",
422 "HOURLY",
423 "/MO",
424 &interval_hours.to_string(),
425 "/RL",
426 "HIGHEST",
427 ])
428 .output()
429 .map_err(|e| e.to_string())?;
430
431 if !out.status.success() {
432 return Err(format!(
433 "schtasks failed: {}",
434 String::from_utf8_lossy(&out.stderr)
435 ));
436 }
437
438 Ok(ScheduleInfo {
439 enabled: true,
440 mechanism: "Task Scheduler".into(),
441 interval_hours,
442 scheduler_path: None,
443 last_check: None,
444 })
445}
446
447#[cfg(target_os = "windows")]
448fn remove_windows_task() -> Result<(), String> {
449 let _ = std::process::Command::new("schtasks")
450 .args(["/Delete", "/F", "/TN", "lean-ctx autoupdate"])
451 .output();
452 Ok(())
453}
454
455#[cfg(target_os = "windows")]
456fn windows_status() -> ScheduleInfo {
457 let out = std::process::Command::new("schtasks")
458 .args(["/Query", "/TN", "lean-ctx autoupdate", "/FO", "LIST"])
459 .output();
460
461 let enabled = out.as_ref().is_ok_and(|o| o.status.success());
462 ScheduleInfo {
463 enabled,
464 mechanism: "Task Scheduler".into(),
465 interval_hours: if enabled { 6 } else { 0 },
466 scheduler_path: None,
467 last_check: read_last_check_time(),
468 }
469}
470
471fn read_last_check_time() -> Option<String> {
474 let path = crate::core::data_dir::lean_ctx_data_dir()
475 .ok()?
476 .join("latest-version.json");
477 let content = std::fs::read_to_string(path).ok()?;
478 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
479 let ts = v["checked_at"].as_u64()?;
480 let dt = chrono::DateTime::from_timestamp(ts as i64, 0)?;
481 Some(dt.format("%Y-%m-%d %H:%M UTC").to_string())
482}
483
484pub fn has_user_decided() -> bool {
486 let Some(home) = dirs::home_dir() else {
487 return false;
488 };
489 let config_path = home.join(".lean-ctx").join("config.toml");
490 let content = std::fs::read_to_string(config_path).unwrap_or_default();
491 content.contains("auto_update")
492}
493
494pub fn set_auto_update(enabled: bool, notify_only: bool, interval_hours: u64) {
497 let Some(home) = dirs::home_dir() else {
498 return;
499 };
500 let config_dir = home.join(".lean-ctx");
501 let _ = std::fs::create_dir_all(&config_dir);
502 let config_path = config_dir.join("config.toml");
503
504 let mut doc = crate::config_io::load_toml_document(&config_path);
505 apply_auto_update(&mut doc, enabled, notify_only, interval_hours);
506 let _ = crate::config_io::write_toml_document(&config_path, &doc);
507}
508
509fn apply_auto_update(
512 doc: &mut toml_edit::DocumentMut,
513 enabled: bool,
514 notify_only: bool,
515 interval_hours: u64,
516) {
517 let updates = doc["updates"].or_insert(toml_edit::table());
518 updates["auto_update"] = toml_edit::value(enabled);
519 updates["check_interval_hours"] = toml_edit::value(interval_hours as i64);
520 updates["notify_only"] = toml_edit::value(notify_only);
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 #[test]
528 fn schedule_info_display_disabled() {
529 let info = ScheduleInfo {
530 enabled: false,
531 mechanism: "none".into(),
532 interval_hours: 0,
533 scheduler_path: None,
534 last_check: None,
535 };
536 assert!(info.to_string().contains("disabled"));
537 }
538
539 #[test]
540 fn schedule_info_display_enabled() {
541 let info = ScheduleInfo {
542 enabled: true,
543 mechanism: "LaunchAgent".into(),
544 interval_hours: 6,
545 scheduler_path: Some(PathBuf::from("/tmp/test.plist")),
546 last_check: Some("2026-05-17 10:00 UTC".into()),
547 };
548 let s = info.to_string();
549 assert!(s.contains("enabled"));
550 assert!(s.contains("LaunchAgent"));
551 assert!(s.contains("6h"));
552 }
553
554 #[test]
555 fn apply_auto_update_preserves_existing_keys_and_comments() {
556 let mut doc = "\
557# important user comment
558buddy_enabled = true
559"
560 .parse::<toml_edit::DocumentMut>()
561 .unwrap();
562
563 apply_auto_update(&mut doc, true, false, 12);
564
565 let result = doc.to_string();
566 assert!(result.contains("auto_update = true"));
567 assert!(result.contains("check_interval_hours = 12"));
568 assert!(result.contains("notify_only = false"));
569 assert!(result.contains("buddy_enabled = true"));
571 assert!(result.contains("# important user comment"));
572 }
573
574 #[test]
575 fn apply_auto_update_overwrites_only_updates_section() {
576 let mut doc = "\
577[updates]
578auto_update = false
579check_interval_hours = 99
580"
581 .parse::<toml_edit::DocumentMut>()
582 .unwrap();
583
584 apply_auto_update(&mut doc, true, true, 6);
585
586 let result = doc.to_string();
587 assert!(result.contains("auto_update = true"));
588 assert!(result.contains("check_interval_hours = 6"));
589 assert!(result.contains("notify_only = true"));
590 assert!(!result.contains("check_interval_hours = 99"));
591 }
592
593 #[test]
594 fn has_user_decided_false_by_default() {
595 let _ = has_user_decided();
598 }
599}