1use 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#[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#[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#[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
476fn 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
489pub 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
499pub 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 let _ = has_user_decided();
580 }
581}