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
6const LABEL: &str = "com.leanctx.autoupdate";
7
8#[derive(Debug, Clone)]
9pub struct ScheduleInfo {
10    pub enabled: bool,
11    pub mechanism: String,
12    pub interval_hours: u64,
13    pub scheduler_path: Option<PathBuf>,
14    pub last_check: Option<String>,
15}
16
17impl std::fmt::Display for ScheduleInfo {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        if self.enabled {
20            write!(
21                f,
22                "Auto-update: enabled ({}, every {}h)",
23                self.mechanism, self.interval_hours
24            )?;
25            if let Some(ref path) = self.scheduler_path {
26                write!(f, "\n  Scheduler: {}", path.display())?;
27            }
28            if let Some(ref last) = self.last_check {
29                write!(f, "\n  Last check: {last}")?;
30            }
31        } else {
32            write!(f, "Auto-update: disabled")?;
33        }
34        Ok(())
35    }
36}
37
38pub fn install_schedule(interval_hours: u64) -> Result<ScheduleInfo, String> {
39    let binary = std::env::current_exe().map_err(|e| format!("Cannot locate binary: {e}"))?;
40    let interval_secs = interval_hours * 3600;
41
42    #[cfg(target_os = "macos")]
43    return install_macos_launchagent(&binary, interval_secs, 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, interval_secs);
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  </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// ─── Linux ───────────────────────────────────────────────
213
214#[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")]
337fn remove_linux_scheduler() -> Result<(), String> {
338    let dir = systemd_dir();
339    let timer = dir.join("lean-ctx-autoupdate.timer");
340    let service = dir.join("lean-ctx-autoupdate.service");
341    if timer.exists() {
342        let _ = std::process::Command::new("systemctl")
343            .args(["--user", "disable", "--now", "lean-ctx-autoupdate.timer"])
344            .output();
345        let _ = std::fs::remove_file(&timer);
346        let _ = std::fs::remove_file(&service);
347        let _ = std::process::Command::new("systemctl")
348            .args(["--user", "daemon-reload"])
349            .output();
350    }
351
352    if let Ok(out) = std::process::Command::new("crontab").arg("-l").output() {
353        let existing = String::from_utf8_lossy(&out.stdout).to_string();
354        if existing.contains("lean-ctx") && existing.contains("update") {
355            let filtered: String = existing
356                .lines()
357                .filter(|l| !(l.contains("lean-ctx") && l.contains("update")))
358                .collect::<Vec<_>>()
359                .join("\n")
360                + "\n";
361            if let Ok(mut child) = std::process::Command::new("crontab")
362                .arg("-")
363                .stdin(std::process::Stdio::piped())
364                .spawn()
365            {
366                use std::io::Write;
367                if let Some(mut stdin) = child.stdin.take() {
368                    let _ = stdin.write_all(filtered.as_bytes());
369                }
370                let _ = child.wait();
371            }
372        }
373    }
374    Ok(())
375}
376
377#[cfg(target_os = "linux")]
378fn linux_status() -> ScheduleInfo {
379    let timer = systemd_dir().join("lean-ctx-autoupdate.timer");
380    if timer.exists() {
381        return ScheduleInfo {
382            enabled: true,
383            mechanism: "systemd timer".into(),
384            interval_hours: 6,
385            scheduler_path: Some(timer),
386            last_check: read_last_check_time(),
387        };
388    }
389    if let Ok(out) = std::process::Command::new("crontab").arg("-l").output() {
390        let crontab = String::from_utf8_lossy(&out.stdout);
391        if crontab.contains("lean-ctx") && crontab.contains("update") {
392            return ScheduleInfo {
393                enabled: true,
394                mechanism: "cron".into(),
395                interval_hours: 6,
396                scheduler_path: None,
397                last_check: read_last_check_time(),
398            };
399        }
400    }
401    ScheduleInfo {
402        enabled: false,
403        mechanism: "none".into(),
404        interval_hours: 0,
405        scheduler_path: None,
406        last_check: None,
407    }
408}
409
410// ─── Windows ─────────────────────────────────────────────
411
412#[cfg(target_os = "windows")]
413fn install_windows_task(
414    binary: &std::path::Path,
415    interval_hours: u64,
416) -> Result<ScheduleInfo, String> {
417    let binary_str = binary.to_string_lossy();
418    let out = std::process::Command::new("schtasks")
419        .args([
420            "/Create",
421            "/F",
422            "/TN",
423            "lean-ctx autoupdate",
424            "/TR",
425            &format!("\"{binary_str}\" update --quiet"),
426            "/SC",
427            "HOURLY",
428            "/MO",
429            &interval_hours.to_string(),
430            "/RL",
431            "HIGHEST",
432        ])
433        .output()
434        .map_err(|e| e.to_string())?;
435
436    if !out.status.success() {
437        return Err(format!(
438            "schtasks failed: {}",
439            String::from_utf8_lossy(&out.stderr)
440        ));
441    }
442
443    Ok(ScheduleInfo {
444        enabled: true,
445        mechanism: "Task Scheduler".into(),
446        interval_hours,
447        scheduler_path: None,
448        last_check: None,
449    })
450}
451
452#[cfg(target_os = "windows")]
453fn remove_windows_task() -> Result<(), String> {
454    let _ = std::process::Command::new("schtasks")
455        .args(["/Delete", "/F", "/TN", "lean-ctx autoupdate"])
456        .output();
457    Ok(())
458}
459
460#[cfg(target_os = "windows")]
461fn windows_status() -> ScheduleInfo {
462    let out = std::process::Command::new("schtasks")
463        .args(["/Query", "/TN", "lean-ctx autoupdate", "/FO", "LIST"])
464        .output();
465
466    let enabled = out.as_ref().is_ok_and(|o| o.status.success());
467    ScheduleInfo {
468        enabled,
469        mechanism: "Task Scheduler".into(),
470        interval_hours: if enabled { 6 } else { 0 },
471        scheduler_path: None,
472        last_check: read_last_check_time(),
473    }
474}
475
476// ─── Shared ──────────────────────────────────────────────
477
478fn read_last_check_time() -> Option<String> {
479    let path = crate::core::data_dir::lean_ctx_data_dir()
480        .ok()?
481        .join("latest-version.json");
482    let content = std::fs::read_to_string(path).ok()?;
483    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
484    let ts = v["checked_at"].as_u64()?;
485    let dt = chrono::DateTime::from_timestamp(ts as i64, 0)?;
486    Some(dt.format("%Y-%m-%d %H:%M UTC").to_string())
487}
488
489/// Check if the user has ever configured `auto_update` (the key exists in config.toml).
490pub fn has_user_decided() -> bool {
491    let Some(home) = dirs::home_dir() else {
492        return false;
493    };
494    let config_path = home.join(".lean-ctx").join("config.toml");
495    let content = std::fs::read_to_string(config_path).unwrap_or_default();
496    content.contains("auto_update")
497}
498
499/// Write the auto_update setting to config.toml.
500pub fn set_auto_update(enabled: bool, notify_only: bool, interval_hours: u64) {
501    let Some(home) = dirs::home_dir() else {
502        return;
503    };
504    let config_dir = home.join(".lean-ctx");
505    let _ = std::fs::create_dir_all(&config_dir);
506    let config_path = config_dir.join("config.toml");
507    let mut content = std::fs::read_to_string(&config_path).unwrap_or_default();
508
509    if let Some(start) = content.find("[updates]") {
510        let section_end = content[start + 9..]
511            .find("\n[")
512            .map_or(content.len(), |i| start + 9 + i);
513        content = format!("{}{}", &content[..start], &content[section_end..]);
514    }
515
516    if !content.is_empty() && !content.ends_with('\n') {
517        content.push('\n');
518    }
519    content.push_str(&format!(
520        "\n[updates]\nauto_update = {enabled}\ncheck_interval_hours = {interval_hours}\nnotify_only = {notify_only}\n"
521    ));
522
523    let _ = std::fs::write(&config_path, content);
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn schedule_info_display_disabled() {
532        let info = ScheduleInfo {
533            enabled: false,
534            mechanism: "none".into(),
535            interval_hours: 0,
536            scheduler_path: None,
537            last_check: None,
538        };
539        assert!(info.to_string().contains("disabled"));
540    }
541
542    #[test]
543    fn schedule_info_display_enabled() {
544        let info = ScheduleInfo {
545            enabled: true,
546            mechanism: "LaunchAgent".into(),
547            interval_hours: 6,
548            scheduler_path: Some(PathBuf::from("/tmp/test.plist")),
549            last_check: Some("2026-05-17 10:00 UTC".into()),
550        };
551        let s = info.to_string();
552        assert!(s.contains("enabled"));
553        assert!(s.contains("LaunchAgent"));
554        assert!(s.contains("6h"));
555    }
556
557    #[test]
558    fn set_auto_update_writes_config() {
559        let tmp = tempfile::tempdir().unwrap();
560        let config_path = tmp.path().join("config.toml");
561        std::fs::write(&config_path, "buddy_enabled = true\n").unwrap();
562
563        let mut content = std::fs::read_to_string(&config_path).unwrap();
564        content.push_str(
565            "\n[updates]\nauto_update = true\ncheck_interval_hours = 12\nnotify_only = false\n",
566        );
567        std::fs::write(&config_path, &content).unwrap();
568
569        let result = std::fs::read_to_string(&config_path).unwrap();
570        assert!(result.contains("auto_update = true"));
571        assert!(result.contains("check_interval_hours = 12"));
572        assert!(result.contains("buddy_enabled = true"));
573    }
574
575    #[test]
576    fn has_user_decided_false_by_default() {
577        // In test env, the config likely doesn't contain auto_update
578        // This tests the function doesn't panic
579        let _ = has_user_decided();
580    }
581}