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> {
221 if let Some(id) = canonical_issue(raw, None) {
222 return Ok(id);
223 }
224 super::held_issue().ok_or_else(|| "ljos_note: pass an issue id".into())
225}
226
227#[must_use]
229pub fn prompt_claims() -> String {
230 String::new()
231}
232
233#[must_use]
235pub fn named_open_issue(_message: &str) -> Option<String> {
236 None
237}
238
239pub fn print_brief_once() {}
241
242#[must_use]
244pub fn commit_recall_line(_session: &str, _repo: &str, _subject: &str) -> Option<String> {
245 None
246}
247
248#[must_use]
250pub fn note_after_commit(repo: &str, head: &str, subject: &str) -> String {
251 let subject = subject.split_whitespace().collect::<Vec<_>>().join(" ");
252 let short = head.chars().take(12).collect::<String>();
253 let line = if subject.is_empty() {
254 format!("note: {short} in {repo}")
255 } else {
256 format!("note: {short} {subject}")
257 };
258 log_line(&line);
259 line
260}
261
262#[must_use]
264pub fn guard_suspended() -> bool {
265 ljos_off() || (temp_home().is_some() && !guard_forced_on())
266}
267
268#[must_use]
270pub fn guard_forced_on() -> bool {
271 matches!(
272 std::env::var("LJOS_GUARD").ok().as_deref(),
273 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
274 )
275}
276
277fn ljos_off() -> bool {
278 matches!(
279 std::env::var("LJOS_OFF").ok().as_deref(),
280 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
281 )
282}
283
284#[must_use]
286pub fn ljos_is_off() -> bool {
287 ljos_off()
288}
289
290#[must_use]
292pub fn temp_home() -> Option<PathBuf> {
293 std::env::var_os("HOME")
294 .map(PathBuf::from)
295 .filter(|home| path_is_temp(Some(home.as_path())))
296}
297
298#[must_use]
300pub fn guard_off_reason() -> Option<String> {
301 if ljos_off() {
302 return Some("off: LJOS_OFF is set".to_string());
303 }
304 if guard_forced_on() {
305 return None;
306 }
307 temp_home().map(|home| {
308 format!(
309 "off: HOME ({}) is a temporary directory, and ljos stays out of test sandboxes. \
310 `LJOS_GUARD=on` keeps it on",
311 home.display()
312 )
313 })
314}
315
316#[must_use]
318pub fn path_is_temp(path: Option<&Path>) -> bool {
319 let Some(path) = path else {
320 return false;
321 };
322 if path.as_os_str().is_empty() {
323 return false;
324 }
325 let path = canonical_existing_prefix(path);
326 resolved_path_is_temp(&path)
327}
328
329fn canonical_existing_prefix(path: &Path) -> PathBuf {
334 let mut missing = Vec::new();
335 let mut cur = path.to_path_buf();
336 loop {
337 if let Ok(mut real) = std::fs::canonicalize(&cur) {
338 for name in missing.iter().rev() {
339 real.push(name);
340 }
341 return real;
342 }
343 let Some(name) = cur.file_name().map(|name| name.to_os_string()) else {
344 return path.to_path_buf();
345 };
346 missing.push(name);
347 if !cur.pop() {
348 return path.to_path_buf();
349 }
350 }
351}
352
353#[must_use]
355pub fn resolved_path_is_temp(path: &Path) -> bool {
356 temp_roots().iter().any(|root| path.starts_with(root))
357}
358
359#[cfg(test)]
360thread_local! {
361 pub(crate) static TEST_TEMP_ROOTS: std::cell::RefCell<Option<Vec<PathBuf>>> =
363 const { std::cell::RefCell::new(None) };
364}
365
366fn temp_roots() -> Vec<PathBuf> {
367 #[cfg(test)]
368 if let Some(roots) = TEST_TEMP_ROOTS.with(|r| r.borrow().clone()) {
369 return roots;
370 }
371 let mut roots = vec![
372 std::env::temp_dir(),
373 PathBuf::from("/tmp"),
374 PathBuf::from("/var/tmp"),
375 PathBuf::from("/private/tmp"),
376 PathBuf::from("/dev/shm"),
377 ];
378 if let Some(tmp) = std::env::var_os("TMPDIR").filter(|t| !t.is_empty()) {
379 roots.push(PathBuf::from(tmp));
380 }
381 roots
382 .into_iter()
383 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
384 .collect()
385}
386
387#[must_use]
389pub fn books(_event: &str) -> bool {
390 false
391}
392
393#[must_use]
395pub fn observe(raw: &str, call: &super::HookCall) -> (String, Vec<String>) {
396 if call.event == "PostToolUse" && call.cue.contains("git commit") {
397 let subject = call
398 .cue
399 .split_once("-m ")
400 .map(|(_, rest)| rest.trim().trim_matches('"').to_string())
401 .unwrap_or_default();
402 let repo = serde_json::from_str::<Value>(raw)
403 .ok()
404 .and_then(|v| v.get("cwd").and_then(Value::as_str).map(str::to_string))
405 .unwrap_or_default();
406 let _ = note_after_commit(&repo, "commit", &subject);
407 }
408 (String::new(), Vec::new())
409}
410
411#[must_use]
413pub fn recall_for(_raw: &str, _call: &super::HookCall) -> String {
414 String::new()
415}
416
417pub(crate) type ActingFor = ((u32, u64), Option<String>);
418
419pub(crate) fn act_for(_owner: Option<ActingFor>) {}
420
421pub(crate) fn observe_queued(_raw: &str, _call: &super::HookCall) -> bool {
422 false
423}
424
425pub(crate) fn session_key(call: &super::HookCall) -> String {
426 call.session
427 .as_deref()
428 .map(str::trim)
429 .filter(|s| !s.is_empty())
430 .map(str::to_string)
431 .unwrap_or_else(|| std::process::id().to_string())
432}
433
434pub(crate) fn hook_owner() -> (u32, u64) {
435 (0, 0)
436}
437
438pub(crate) fn replace_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
440 use std::io::Write;
441 if let Some(dir) = path.parent() {
442 std::fs::create_dir_all(dir)?;
443 }
444 let dir = path.parent().unwrap_or_else(|| Path::new("."));
445 let stamp = std::time::SystemTime::now()
446 .duration_since(std::time::UNIX_EPOCH)
447 .unwrap_or_default()
448 .as_nanos();
449 let mut tmp_name = path
450 .file_name()
451 .map(|name| name.to_os_string())
452 .unwrap_or_else(|| "note".into());
453 tmp_name.push(format!(".tmp-{}-{stamp}", std::process::id()));
454 let tmp = dir.join(tmp_name);
455 #[cfg(unix)]
456 let mode = existing_mode(path);
457 {
458 let mut options = std::fs::OpenOptions::new();
459 options.create_new(true).write(true);
460 #[cfg(unix)]
461 {
462 use std::os::unix::fs::OpenOptionsExt;
463 options.mode(0o600);
464 }
465 let mut out = options.open(&tmp)?;
466 out.write_all(bytes)?;
467 out.sync_all()?;
468 }
469 #[cfg(unix)]
470 {
471 use std::os::unix::fs::PermissionsExt;
472 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
473 }
474 if let Err(err) = std::fs::rename(&tmp, path) {
475 let _ = std::fs::remove_file(&tmp);
476 return Err(err);
477 }
478 Ok(())
479}
480
481#[cfg(unix)]
482fn existing_mode(path: &Path) -> u32 {
483 use std::os::unix::fs::PermissionsExt;
484 std::fs::symlink_metadata(path)
485 .ok()
486 .filter(|meta| meta.file_type().is_file())
487 .map(|meta| meta.permissions().mode() & 0o777)
488 .unwrap_or(0o600)
489}
490
491pub(crate) fn read_text_bounded(path: &Path) -> Option<String> {
493 let meta = std::fs::metadata(path).ok()?;
494 if !meta.is_file() || meta.len() > 1_048_576 {
495 return None;
496 }
497 std::fs::read_to_string(path).ok()
498}
499
500#[cfg(test)]
501mod temp_path_tests {
502 use super::*;
503
504 #[test]
507 fn a_missing_path_under_a_symlinked_temp_is_temporary() {
508 let root = tempfile::tempdir().unwrap();
509 let real = root.path().join("private/tmp");
510 std::fs::create_dir_all(&real).unwrap();
511 let link = root.path().join("tmp");
512 std::os::unix::fs::symlink(&real, &link).unwrap();
513 let canon = std::fs::canonicalize(&link).unwrap();
514 TEST_TEMP_ROOTS.with(|roots| roots.replace(Some(vec![canon.clone()])));
515 let missing = link.join("scratch");
516 assert!(
517 path_is_temp(Some(&missing)),
518 "{} against {}",
519 missing.display(),
520 canon.display()
521 );
522 TEST_TEMP_ROOTS.with(|roots| roots.replace(None));
523 }
524}