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