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::paths::cache_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(config_path) = crate::core::config::Config::path() else {
490 return false;
491 };
492 let content = std::fs::read_to_string(config_path).unwrap_or_default();
493 content.contains("auto_update")
494}
495
496pub fn set_auto_update(enabled: bool, notify_only: bool, interval_hours: u64) {
499 let Some(config_path) = crate::core::config::Config::path() else {
500 return;
501 };
502 if let Some(dir) = config_path.parent() {
503 let _ = std::fs::create_dir_all(dir);
504 }
505
506 let mut doc = crate::config_io::load_toml_document(&config_path);
507 apply_auto_update(&mut doc, enabled, notify_only, interval_hours);
508 let _ = crate::config_io::write_toml_document(&config_path, &doc);
509}
510
511fn apply_auto_update(
514 doc: &mut toml_edit::DocumentMut,
515 enabled: bool,
516 notify_only: bool,
517 interval_hours: u64,
518) {
519 let updates = doc["updates"].or_insert(toml_edit::table());
520 updates["auto_update"] = toml_edit::value(enabled);
521 updates["check_interval_hours"] = toml_edit::value(interval_hours as i64);
522 updates["notify_only"] = toml_edit::value(notify_only);
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn schedule_info_display_disabled() {
531 let info = ScheduleInfo {
532 enabled: false,
533 mechanism: "none".into(),
534 interval_hours: 0,
535 scheduler_path: None,
536 last_check: None,
537 };
538 assert!(info.to_string().contains("disabled"));
539 }
540
541 #[test]
542 fn schedule_info_display_enabled() {
543 let info = ScheduleInfo {
544 enabled: true,
545 mechanism: "LaunchAgent".into(),
546 interval_hours: 6,
547 scheduler_path: Some(PathBuf::from("/tmp/test.plist")),
548 last_check: Some("2026-05-17 10:00 UTC".into()),
549 };
550 let s = info.to_string();
551 assert!(s.contains("enabled"));
552 assert!(s.contains("LaunchAgent"));
553 assert!(s.contains("6h"));
554 }
555
556 #[test]
557 fn apply_auto_update_preserves_existing_keys_and_comments() {
558 let mut doc = "\
559# important user comment
560buddy_enabled = true
561"
562 .parse::<toml_edit::DocumentMut>()
563 .unwrap();
564
565 apply_auto_update(&mut doc, true, false, 12);
566
567 let result = doc.to_string();
568 assert!(result.contains("auto_update = true"));
569 assert!(result.contains("check_interval_hours = 12"));
570 assert!(result.contains("notify_only = false"));
571 assert!(result.contains("buddy_enabled = true"));
573 assert!(result.contains("# important user comment"));
574 }
575
576 #[test]
577 fn apply_auto_update_overwrites_only_updates_section() {
578 let mut doc = "\
579[updates]
580auto_update = false
581check_interval_hours = 99
582"
583 .parse::<toml_edit::DocumentMut>()
584 .unwrap();
585
586 apply_auto_update(&mut doc, true, true, 6);
587
588 let result = doc.to_string();
589 assert!(result.contains("auto_update = true"));
590 assert!(result.contains("check_interval_hours = 6"));
591 assert!(result.contains("notify_only = true"));
592 assert!(!result.contains("check_interval_hours = 99"));
593 }
594
595 #[test]
596 fn has_user_decided_false_by_default() {
597 let _ = has_user_decided();
600 }
601}