1use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
9use std::time::Duration;
10
11use serde_json::Value;
12
13static LAST_ACTIVITY: AtomicU64 = AtomicU64::new(0);
14static DURABLE_WRITES: AtomicUsize = AtomicUsize::new(0);
15
16pub struct DurableWrite(());
18
19impl Drop for DurableWrite {
20 fn drop(&mut self) {
21 DURABLE_WRITES.fetch_sub(1, Ordering::AcqRel);
22 }
23}
24
25#[must_use]
27pub fn durable_write() -> DurableWrite {
28 DURABLE_WRITES.fetch_add(1, Ordering::AcqRel);
29 DurableWrite(())
30}
31
32#[must_use]
34pub fn durable_pending() -> bool {
35 DURABLE_WRITES.load(Ordering::Acquire) > 0
36}
37
38const MCP_WRITES: &[&str] = &[
40 "ljos_remember",
41 "ljos_prefer",
42 "ljos_forget",
43 "ljos_deed",
44 "ljos_rule",
45 "ljos_vote",
46 "ljos_learn",
47 "ljos_trust",
48 "ljos_persona",
49 "ljos_playbook",
50 "ljos_graded",
51 "ljos_consolidate",
52 "ljos_calibrate",
53 "ljos_habit",
54];
55
56#[must_use]
58pub fn mcp_tool_writes(name: &str) -> bool {
59 MCP_WRITES.contains(&name)
60}
61
62pub fn before_mcp_write(_name: &str, _args: Option<&serde_json::Map<String, Value>>) {}
64
65pub fn mcp_shutdown() {}
67
68pub fn touch_activity() {
70 LAST_ACTIVITY.store(now_secs(), Ordering::Relaxed);
71}
72
73#[must_use]
75pub fn last_activity() -> u64 {
76 LAST_ACTIVITY.load(Ordering::Relaxed)
77}
78
79#[must_use]
81pub fn now_secs() -> u64 {
82 std::time::SystemTime::now()
83 .duration_since(std::time::UNIX_EPOCH)
84 .map(|elapsed| elapsed.as_secs())
85 .unwrap_or(0)
86}
87
88pub fn note_mcp_process_call() {}
90
91pub fn resume_seat() {}
93
94#[must_use]
96pub fn idle_limit() -> Option<Duration> {
97 idle_limit_from(std::env::var("LJOS_MCP_IDLE").ok().as_deref())
98}
99
100#[must_use]
102pub fn idle_limit_from(raw: Option<&str>) -> Option<Duration> {
103 let Some(raw) = raw.map(str::trim).filter(|text| !text.is_empty()) else {
104 return Some(Duration::from_secs(30 * 60));
105 };
106 let lowered = raw.to_ascii_lowercase();
107 if matches!(lowered.as_str(), "0" | "off" | "false" | "no") {
108 return None;
109 }
110 match super::reap::parse_idle(raw) {
111 Ok(window) if window.is_zero() => None,
112 Ok(window) => Some(window),
113 Err(_) => Some(Duration::from_secs(30 * 60)),
114 }
115}
116
117#[must_use]
119pub fn idle_due(now: u64, last: u64, limit: Duration) -> bool {
120 last > 0 && now.saturating_sub(last) >= limit.as_secs()
121}
122
123pub fn spawn_idle_finish() {}
125
126#[must_use]
128pub fn blank_issue(id: &str) -> bool {
129 matches!(
130 id.trim().to_ascii_lowercase().as_str(),
131 "" | "none"
132 | "null"
133 | "nil"
134 | "n/a"
135 | "na"
136 | "undefined"
137 | "unknown"
138 | "-"
139 | "n.a."
140 | "n.a"
141 )
142}
143
144#[must_use]
147pub fn canonical_issue(raw: Option<&str>, _session: Option<&str>) -> Option<String> {
148 raw.map(str::trim)
149 .filter(|id| !blank_issue(id))
150 .map(str::to_string)
151}
152
153#[must_use]
155pub fn session_issue(_session: Option<&str>) -> Option<String> {
156 None
157}
158
159#[must_use]
161pub fn open_issue() -> Option<String> {
162 None
163}
164
165pub fn note_finished(_issue: &str) {}
167
168pub fn start_work(_issue: Option<&str>, _repo: Option<&str>, task: &str) -> Result<String, String> {
174 let task = task.split_whitespace().collect::<Vec<_>>().join(" ");
175 if task.is_empty() {
176 return Err("ljos_start: pass the task in one sentence".into());
177 }
178 log_line(&format!("note: {task}"));
179 Ok(format!("noted: {task}"))
180}
181
182pub struct ShutdownFinish;
184
185impl Drop for ShutdownFinish {
186 fn drop(&mut self) {}
187}
188
189pub fn log_line(line: &str) {
191 let dir = super::runtime_dir();
192 let _ = std::fs::create_dir_all(&dir);
193 let Ok(mut file) = std::fs::OpenOptions::new()
194 .create(true)
195 .append(true)
196 .open(dir.join("book.log"))
197 else {
198 return;
199 };
200 use std::io::Write;
201 let _ = writeln!(file, "{line}");
202}
203
204#[must_use]
206pub fn seat_session() -> String {
207 std::env::var("LJOS_SEAT")
208 .ok()
209 .map(|raw| raw.trim().to_string())
210 .filter(|raw| !raw.is_empty())
211 .unwrap_or_else(|| std::process::id().to_string())
212}
213
214pub fn note_issue(raw: Option<&str>) -> Result<String, String> {
220 canonical_issue(raw, None).ok_or_else(|| "ljos_note: pass an issue id".into())
221}
222
223#[must_use]
225pub fn prompt_claims() -> String {
226 String::new()
227}
228
229#[must_use]
231pub fn named_open_issue(_message: &str) -> Option<String> {
232 None
233}
234
235pub fn note_ask(_raw: &str, _call: &super::HookCall, _reason: &str) {}
237
238pub fn print_brief_once() {}
240
241#[must_use]
243pub fn commit_recall_line(_session: &str, _repo: &str, _subject: &str) -> Option<String> {
244 None
245}
246
247#[must_use]
249pub fn note_after_commit(repo: &str, head: &str, subject: &str) -> String {
250 let subject = subject.split_whitespace().collect::<Vec<_>>().join(" ");
251 let short = head.chars().take(12).collect::<String>();
252 let line = if subject.is_empty() {
253 format!("note: {short} in {repo}")
254 } else {
255 format!("note: {short} {subject}")
256 };
257 log_line(&line);
258 line
259}
260
261#[must_use]
263pub fn guard_suspended() -> bool {
264 ljos_off() || (temp_home().is_some() && !guard_forced_on())
265}
266
267#[must_use]
269pub fn guard_forced_on() -> bool {
270 matches!(
271 std::env::var("LJOS_GUARD").ok().as_deref(),
272 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
273 )
274}
275
276fn ljos_off() -> bool {
277 matches!(
278 std::env::var("LJOS_OFF").ok().as_deref(),
279 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
280 )
281}
282
283#[must_use]
285pub fn ljos_is_off() -> bool {
286 ljos_off()
287}
288
289#[must_use]
291pub fn temp_home() -> Option<PathBuf> {
292 std::env::var_os("HOME")
293 .map(PathBuf::from)
294 .filter(|home| path_is_temp(Some(home.as_path())))
295}
296
297#[must_use]
299pub fn guard_off_reason() -> Option<String> {
300 if ljos_off() {
301 return Some("off: LJOS_OFF is set".to_string());
302 }
303 if guard_forced_on() {
304 return None;
305 }
306 temp_home().map(|home| {
307 format!(
308 "off: HOME ({}) is a temporary directory, and ljos stays out of test sandboxes. \
309 `LJOS_GUARD=on` keeps it on",
310 home.display()
311 )
312 })
313}
314
315#[must_use]
317pub fn path_is_temp(path: Option<&Path>) -> bool {
318 let Some(path) = path else {
319 return false;
320 };
321 if path.as_os_str().is_empty() {
322 return false;
323 }
324 let path = canonical_existing_prefix(path);
325 resolved_path_is_temp(&path)
326}
327
328fn canonical_existing_prefix(path: &Path) -> PathBuf {
333 let mut missing = Vec::new();
334 let mut cur = path.to_path_buf();
335 loop {
336 if let Ok(mut real) = std::fs::canonicalize(&cur) {
337 for name in missing.iter().rev() {
338 real.push(name);
339 }
340 return real;
341 }
342 let Some(name) = cur.file_name().map(|name| name.to_os_string()) else {
343 return path.to_path_buf();
344 };
345 missing.push(name);
346 if !cur.pop() {
347 return path.to_path_buf();
348 }
349 }
350}
351
352#[must_use]
354pub fn resolved_path_is_temp(path: &Path) -> bool {
355 temp_roots().iter().any(|root| path.starts_with(root))
356}
357
358#[cfg(test)]
359thread_local! {
360 pub(crate) static TEST_TEMP_ROOTS: std::cell::RefCell<Option<Vec<PathBuf>>> =
362 const { std::cell::RefCell::new(None) };
363}
364
365fn temp_roots() -> Vec<PathBuf> {
366 #[cfg(test)]
367 if let Some(roots) = TEST_TEMP_ROOTS.with(|r| r.borrow().clone()) {
368 return roots;
369 }
370 let mut roots = vec![
371 std::env::temp_dir(),
372 PathBuf::from("/tmp"),
373 PathBuf::from("/var/tmp"),
374 PathBuf::from("/private/tmp"),
375 PathBuf::from("/dev/shm"),
376 ];
377 if let Some(tmp) = std::env::var_os("TMPDIR").filter(|t| !t.is_empty()) {
378 roots.push(PathBuf::from(tmp));
379 }
380 roots
381 .into_iter()
382 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
383 .collect()
384}
385
386#[must_use]
388pub fn books(_event: &str) -> bool {
389 false
390}
391
392#[must_use]
394pub fn observe(raw: &str, call: &super::HookCall) -> (String, Vec<String>) {
395 if call.event == "PostToolUse" && call.cue.contains("git commit") {
396 let subject = call
397 .cue
398 .split_once("-m ")
399 .map(|(_, rest)| rest.trim().trim_matches('"').to_string())
400 .unwrap_or_default();
401 let repo = serde_json::from_str::<Value>(raw)
402 .ok()
403 .and_then(|v| v.get("cwd").and_then(Value::as_str).map(str::to_string))
404 .unwrap_or_default();
405 let _ = note_after_commit(&repo, "commit", &subject);
406 }
407 (String::new(), Vec::new())
408}
409
410#[must_use]
412pub fn recall_for(_raw: &str, _call: &super::HookCall) -> String {
413 String::new()
414}
415
416pub(crate) type ActingFor = ((u32, u64), Option<String>);
417
418pub(crate) fn act_for(_owner: Option<ActingFor>) {}
419
420pub(crate) fn observe_queued(_raw: &str, _call: &super::HookCall) -> bool {
421 false
422}
423
424pub(crate) fn session_key(call: &super::HookCall) -> String {
425 call.session
426 .as_deref()
427 .map(str::trim)
428 .filter(|s| !s.is_empty())
429 .map(str::to_string)
430 .unwrap_or_else(|| std::process::id().to_string())
431}
432
433pub(crate) fn hook_owner() -> (u32, u64) {
434 (0, 0)
435}
436
437pub(crate) fn note_ask_within(_raw: &str, _call: &super::HookCall, _reason: &str) -> bool {
438 true
439}
440
441pub(crate) fn replace_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
443 use std::io::Write;
444 if let Some(dir) = path.parent() {
445 std::fs::create_dir_all(dir)?;
446 }
447 let dir = path.parent().unwrap_or_else(|| Path::new("."));
448 let stamp = std::time::SystemTime::now()
449 .duration_since(std::time::UNIX_EPOCH)
450 .unwrap_or_default()
451 .as_nanos();
452 let mut tmp_name = path
453 .file_name()
454 .map(|name| name.to_os_string())
455 .unwrap_or_else(|| "note".into());
456 tmp_name.push(format!(".tmp-{}-{stamp}", std::process::id()));
457 let tmp = dir.join(tmp_name);
458 #[cfg(unix)]
459 let mode = existing_mode(path);
460 {
461 let mut options = std::fs::OpenOptions::new();
462 options.create_new(true).write(true);
463 #[cfg(unix)]
464 {
465 use std::os::unix::fs::OpenOptionsExt;
466 options.mode(0o600);
467 }
468 let mut out = options.open(&tmp)?;
469 out.write_all(bytes)?;
470 out.sync_all()?;
471 }
472 #[cfg(unix)]
473 {
474 use std::os::unix::fs::PermissionsExt;
475 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
476 }
477 if let Err(err) = std::fs::rename(&tmp, path) {
478 let _ = std::fs::remove_file(&tmp);
479 return Err(err);
480 }
481 Ok(())
482}
483
484#[cfg(unix)]
485fn existing_mode(path: &Path) -> u32 {
486 use std::os::unix::fs::PermissionsExt;
487 std::fs::symlink_metadata(path)
488 .ok()
489 .filter(|meta| meta.file_type().is_file())
490 .map(|meta| meta.permissions().mode() & 0o777)
491 .unwrap_or(0o600)
492}
493
494pub(crate) fn read_text_bounded(path: &Path) -> Option<String> {
496 let meta = std::fs::metadata(path).ok()?;
497 if !meta.is_file() || meta.len() > 1_048_576 {
498 return None;
499 }
500 std::fs::read_to_string(path).ok()
501}
502
503#[cfg(test)]
504mod temp_path_tests {
505 use super::*;
506
507 #[test]
510 fn a_missing_path_under_a_symlinked_temp_is_temporary() {
511 let root = tempfile::tempdir().unwrap();
512 let real = root.path().join("private/tmp");
513 std::fs::create_dir_all(&real).unwrap();
514 let link = root.path().join("tmp");
515 std::os::unix::fs::symlink(&real, &link).unwrap();
516 let canon = std::fs::canonicalize(&link).unwrap();
517 TEST_TEMP_ROOTS.with(|roots| roots.replace(Some(vec![canon.clone()])));
518 let missing = link.join("scratch");
519 assert!(
520 path_is_temp(Some(&missing)),
521 "{} against {}",
522 missing.display(),
523 canon.display()
524 );
525 TEST_TEMP_ROOTS.with(|roots| roots.replace(None));
526 }
527}