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 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#[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#[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
481fn 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
494pub 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
504pub 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
519fn 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 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 let _ = has_user_decided();
608 }
609}