Skip to main content

lean_ctx/core/
update_scheduler.rs

1//! OS-specific auto-update scheduler management.
2//! Supports macOS LaunchAgent, Linux systemd/cron, Windows Task Scheduler.
3
4use 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// ─── macOS ───────────────────────────────────────────────
93
94#[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// ─── Linux ───────────────────────────────────────────────
204
205#[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// ─── Windows ─────────────────────────────────────────────
406
407#[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
471// ─── Shared ──────────────────────────────────────────────
472
473fn 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
484/// Check if the user has ever configured `auto_update` (the key exists in config.toml).
485pub 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
494/// Writes the `[updates]` settings to config.toml, preserving all comments,
495/// formatting, and unrelated keys.
496pub 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
509/// Applies the `[updates]` settings onto a TOML document in place. Pure helper
510/// so the merge behavior is unit-testable without touching the real home dir.
511fn 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        // Existing key + comment survive.
570        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        // In test env, the config likely doesn't contain auto_update
596        // This tests the function doesn't panic
597        let _ = has_user_decided();
598    }
599}