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