1use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16use std::time::{Duration, Instant, SystemTime};
17
18use ashlar_control::Client;
19use serde::{Deserialize, Serialize};
20use serde_json::json;
21
22pub use ashlar_control::key::KEY_DIR_ENV;
24
25#[derive(Debug, Clone)]
28pub struct Env {
29 pub home: PathBuf,
31 pub config_roots: Vec<PathBuf>,
33 pub session: String,
35 pub key_dir: Option<PathBuf>,
37 pub no_raise: bool,
40}
41
42impl Env {
43 pub fn from_os() -> Self {
46 let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty());
47 let base = directories::BaseDirs::new();
48 let home = var("ASHLAR_DEBUG_HOME").map(PathBuf::from).unwrap_or_else(|| {
49 base.as_ref().map(|b| b.cache_dir().join("ashlar-debug")).unwrap_or_else(|| std::env::temp_dir().join("ashlar-debug"))
50 });
51 let mut config_roots: Vec<PathBuf> = Vec::new();
52 if let Some(b) = &base {
53 for d in [b.config_dir(), b.config_local_dir(), b.preference_dir(), b.data_dir()] {
54 if !config_roots.iter().any(|r| r == d) {
55 config_roots.push(d.to_path_buf());
56 }
57 }
58 }
59 let session = var("ASHLAR_DEBUG_SESSION").and_then(|s| s.into_string().ok()).unwrap_or_else(|| "default".into());
60 let no_raise = var("ASHLAR_DEBUG_NO_RAISE").is_some_and(|v| truthy(&v.to_string_lossy()));
61 Self { home, config_roots, session, key_dir: var(KEY_DIR_ENV).map(PathBuf::from), no_raise }
62 }
63
64 pub fn under(root: &Path) -> Self {
67 Self { home: root.join("home"), config_roots: vec![root.join("config")], session: "default".into(), key_dir: None, no_raise: false }
68 }
69
70 pub fn sessions_dir(&self) -> PathBuf {
71 self.home.join("sessions")
72 }
73
74 pub fn session_file(&self, name: &str) -> PathBuf {
75 self.sessions_dir().join(format!("{name}.json"))
76 }
77
78 pub fn session_dir(&self, name: &str) -> PathBuf {
80 self.sessions_dir().join(name)
81 }
82}
83
84pub fn truthy(v: &str) -> bool {
86 !matches!(v.trim().to_ascii_lowercase().as_str(), "" | "0" | "false" | "no" | "off")
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct Trace {
92 pub dir: PathBuf,
93 pub step: u32,
95}
96
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct Session {
100 pub port: u16,
101 pub key_dir: PathBuf,
102 #[serde(default)]
104 pub pid: Option<u32>,
105 #[serde(default)]
106 pub exe: Option<String>,
107 #[serde(default)]
109 pub log: Option<PathBuf>,
110 #[serde(default)]
112 pub launched: bool,
113 #[serde(default)]
114 pub trace: Option<Trace>,
115 #[serde(default)]
117 pub no_raise: bool,
118}
119
120pub fn check_name(name: &str) -> Result<(), String> {
122 if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
123 return Err(format!("session name `{name}`: use 1–64 letters, digits, `_` or `-`"));
124 }
125 Ok(())
126}
127
128pub fn load(env: &Env, name: &str) -> Result<Option<Session>, String> {
130 check_name(name)?;
131 let p = env.session_file(name);
132 let text = match std::fs::read(&p) {
133 Ok(t) => t,
134 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
135 Err(e) => return Err(format!("cannot read {}: {e}", p.display())),
136 };
137 if text.len() > 1024 * 1024 {
138 return Err(format!("{} is not a session file (too large)", p.display()));
139 }
140 serde_json::from_slice(&text).map(Some).map_err(|e| format!("{} is damaged ({e}); `close` it or delete the file", p.display()))
141}
142
143pub fn save(env: &Env, name: &str, s: &Session) -> Result<(), String> {
145 check_name(name)?;
146 let dir = env.sessions_dir();
147 std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
148 let p = env.session_file(name);
149 let tmp = dir.join(format!(".{name}.json.{}", std::process::id()));
150 let text = serde_json::to_vec_pretty(s).map_err(|e| e.to_string())?;
151 std::fs::write(&tmp, text).map_err(|e| format!("cannot write {}: {e}", tmp.display()))?;
152 std::fs::rename(&tmp, &p).map_err(|e| {
153 let _ = std::fs::remove_file(&tmp);
154 format!("cannot write {}: {e}", p.display())
155 })
156}
157
158pub fn remove(env: &Env, name: &str) -> Result<(), String> {
160 check_name(name)?;
161 let _ = std::fs::remove_dir_all(env.session_dir(name));
162 match std::fs::remove_file(env.session_file(name)) {
163 Ok(()) => Ok(()),
164 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
165 Err(e) => Err(format!("cannot remove the session file: {e}")),
166 }
167}
168
169pub fn list(env: &Env) -> Vec<String> {
171 let Ok(rd) = std::fs::read_dir(env.sessions_dir()) else { return Vec::new() };
172 let mut names: Vec<String> = rd
173 .filter_map(Result::ok)
174 .filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".json")).map(String::from))
175 .filter(|n| check_name(n).is_ok())
176 .collect();
177 names.sort();
178 names
179}
180
181#[derive(Debug, Clone, PartialEq)]
183pub struct FoundKey {
184 pub port: u16,
185 pub dir: PathBuf,
187 pub modified: SystemTime,
188}
189
190const KEY_PREFIX: &str = "control-key.local-";
191const SCAN_DEPTH: usize = 4;
193const SCAN_BUDGET: usize = 50_000;
195
196pub fn find_keys(roots: &[PathBuf], port: Option<u16>, app: Option<&str>) -> Vec<FoundKey> {
199 let mut found = Vec::new();
200 let mut budget = SCAN_BUDGET;
201 for r in roots {
202 scan(r, SCAN_DEPTH, &mut budget, &mut found);
203 }
204 let app = app.map(str::to_lowercase);
205 found.retain(|k| port.is_none_or(|p| p == k.port) && app.as_deref().is_none_or(|a| k.dir.to_string_lossy().to_lowercase().contains(a)));
206 found.sort_by_key(|k| std::cmp::Reverse(k.modified));
207 found.dedup_by(|a, b| a.port == b.port && a.dir == b.dir);
208 found
209}
210
211fn scan(dir: &Path, depth: usize, budget: &mut usize, out: &mut Vec<FoundKey>) {
212 let Ok(rd) = std::fs::read_dir(dir) else { return };
213 for e in rd.filter_map(Result::ok) {
214 if *budget == 0 {
215 return;
216 }
217 *budget = budget.saturating_sub(1);
218 let Ok(ft) = e.file_type() else { continue };
219 let name = e.file_name();
220 let name = name.to_string_lossy();
221 if ft.is_file() {
222 if let Some(port) = name.strip_prefix(KEY_PREFIX).and_then(|p| p.parse::<u16>().ok()) {
223 let modified = e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
224 out.push(FoundKey { port, dir: dir.to_path_buf(), modified });
225 }
226 } else if ft.is_dir() && depth > 0 && !name.starts_with('.') {
227 scan(&e.path(), depth.saturating_sub(1), budget, out);
228 }
229 }
230}
231
232pub fn ping(port: u16, key_dir: &Path, timeout: Duration) -> Result<Client, String> {
234 let mut c = Client::connect(port, key_dir).map_err(|e| e.to_string())?;
235 c.set_timeout(timeout);
236 c.call("app.ping", json!({})).map_err(|e| e.to_string())?;
237 Ok(c)
238}
239
240pub fn free_port() -> Result<u16, String> {
242 let l = std::net::TcpListener::bind(("127.0.0.1", 0)).map_err(|e| format!("cannot find a free port: {e}"))?;
243 l.local_addr().map(|a| a.port()).map_err(|e| e.to_string())
244}
245
246pub fn tail(p: &Path, n: usize) -> String {
248 use std::io::{Read, Seek, SeekFrom};
249 let Ok(mut f) = std::fs::File::open(p) else { return String::new() };
250 let len = f.metadata().map(|m| m.len()).unwrap_or(0);
251 let _ = f.seek(SeekFrom::Start(len.saturating_sub(64 * 1024)));
252 let mut buf = Vec::new();
253 let _ = f.take(64 * 1024).read_to_end(&mut buf);
254 let text = String::from_utf8_lossy(&buf);
255 let lines: Vec<&str> = text.lines().collect();
256 lines.get(lines.len().saturating_sub(n)..).unwrap_or(&[]).join("\n")
257}
258
259fn private_dir(p: &Path) -> Result<(), String> {
260 let mut b = std::fs::DirBuilder::new();
261 b.recursive(true);
262 #[cfg(unix)]
263 std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
264 b.create(p).map_err(|e| format!("cannot create {}: {e}", p.display()))
265}
266
267pub fn launch(env: &Env, name: &str, exe: &str, args: &[String], wait: Duration) -> Result<(Session, Client), String> {
271 check_name(name)?;
272 let dir = env.session_dir(name);
273 let _ = std::fs::remove_dir_all(&dir);
274 let key_dir = dir.join("key");
275 private_dir(&key_dir)?;
276 let log_path = dir.join("app.log");
277 let log = std::fs::File::create(&log_path).map_err(|e| format!("cannot create {}: {e}", log_path.display()))?;
278 let log2 = log.try_clone().map_err(|e| e.to_string())?;
279 let port = free_port()?;
280 let mut cmd = Command::new(exe);
281 cmd.args(args).arg("--control").arg(port.to_string()).env(KEY_DIR_ENV, &key_dir).stdin(Stdio::null()).stdout(log).stderr(log2);
282 #[cfg(unix)]
284 std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
285 let mut child = cmd.spawn().map_err(|e| format!("cannot start {exe}: {e}"))?;
286 let started = Instant::now();
287 let mut last = String::from("no control key yet");
288 loop {
289 if let Ok(Some(status)) = child.try_wait() {
290 let out = tail(&log_path, 20);
291 let out = if out.is_empty() { String::new() } else { format!("; its last output:\n{out}") };
292 return Err(format!("{exe} exited ({status}) before answering on its control port{out}"));
293 }
294 let mut dirs = vec![key_dir.clone()];
295 if !ashlar_control::key::key_path(&key_dir, port).exists() {
296 dirs.extend(find_keys(&env.config_roots, Some(port), None).into_iter().map(|k| k.dir));
297 }
298 for d in &dirs {
299 if !ashlar_control::key::key_path(d, port).exists() {
300 continue;
301 }
302 match ping(port, d, Duration::from_secs(5)) {
303 Ok(c) => {
304 let s = Session {
305 port,
306 key_dir: d.clone(),
307 pid: Some(child.id()),
308 exe: Some(exe.to_string()),
309 log: Some(log_path),
310 launched: true,
311 trace: None,
312 no_raise: false,
313 };
314 return Ok((s, c));
315 }
316 Err(e) => last = e,
317 }
318 }
319 if started.elapsed() >= wait {
320 let _ = child.kill();
321 let _ = child.wait();
322 let out = tail(&log_path, 20);
323 let out = if out.is_empty() { String::new() } else { format!("\nits last output:\n{out}") };
324 return Err(format!(
325 "{exe} did not answer on 127.0.0.1:{port} within {} ms ({last}); was it built with a control port (`--control <port>`)?{out}",
326 wait.as_millis()
327 ));
328 }
329 std::thread::sleep(Duration::from_millis(40));
330 }
331}
332
333pub fn kill(pid: u32) {
335 #[cfg(unix)]
336 let _ = Command::new("kill").arg(pid.to_string()).stdout(Stdio::null()).stderr(Stdio::null()).status();
337 #[cfg(windows)]
338 let _ = Command::new("taskkill").args(["/PID", &pid.to_string(), "/F"]).stdout(Stdio::null()).stderr(Stdio::null()).status();
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 fn temp(tag: &str) -> tempfile::TempDir {
346 tempfile::Builder::new().prefix(&format!("ashlar-debug-session-{tag}-")).tempdir().unwrap()
347 }
348
349 fn env(home: &Path) -> Env {
350 Env { home: home.to_path_buf(), config_roots: vec![home.join("config")], session: "default".into(), key_dir: None, no_raise: false }
351 }
352
353 #[test]
354 fn sessions_round_trip_and_are_listed() {
355 let tmp = temp("store");
356 let home = tmp.path();
357 let env = env(home);
358 assert_eq!(load(&env, "default").unwrap(), None);
359 let s = Session { port: 7, key_dir: home.join("k"), pid: Some(3), exe: None, log: None, launched: true, trace: None, no_raise: false };
360 save(&env, "default", &s).unwrap();
361 save(&env, "other", &s).unwrap();
362 assert_eq!(load(&env, "default").unwrap(), Some(s));
363 assert_eq!(list(&env), ["default", "other"]);
364 remove(&env, "default").unwrap();
365 remove(&env, "default").unwrap();
366 assert_eq!(list(&env), ["other"]);
367 std::fs::write(env.session_file("bad"), "{").unwrap();
368 assert!(load(&env, "bad").unwrap_err().contains("damaged"));
369 for bad in ["", "../x", "a b", &"x".repeat(65)] {
370 assert!(load(&env, bad).is_err(), "{bad}");
371 }
372 }
373
374 #[test]
375 fn keys_are_found_by_port_and_app() {
376 let tmp = temp("keys");
377 let home = tmp.path();
378 let notes = home.join("config/org.example.Notes/config");
379 let other = home.join("config/Paint");
380 std::fs::create_dir_all(¬es).unwrap();
381 std::fs::create_dir_all(&other).unwrap();
382 std::fs::write(notes.join("control-key.local-7001"), "k").unwrap();
383 std::fs::write(other.join("control-key.local-7002"), "k").unwrap();
384 std::fs::write(other.join("control-key.local-nope"), "k").unwrap();
385 let roots = [home.join("config")];
386 let ports = |p, a| find_keys(&roots, p, a).into_iter().map(|k| k.port).collect::<Vec<_>>();
387 let mut all = ports(None, None);
388 all.sort();
389 assert_eq!(all, [7001, 7002]);
390 assert_eq!(ports(Some(7002), None), [7002]);
391 assert_eq!(ports(None, Some("notes")), [7001]);
392 assert_eq!(ports(Some(7001), Some("paint")), Vec::<u16>::new());
393 assert_eq!(find_keys(&roots, Some(7001), None)[0].dir, notes);
394 let deep = home.join("config/a/b/c/d/e");
396 std::fs::create_dir_all(&deep).unwrap();
397 std::fs::write(deep.join("control-key.local-7003"), "k").unwrap();
398 assert_eq!(ports(Some(7003), None), Vec::<u16>::new());
399 }
400
401 #[test]
402 fn flags_from_the_environment() {
403 for on in ["1", "true", "YES", "on"] {
404 assert!(truthy(on), "{on}");
405 }
406 for off in ["", "0", "false", "No", " off "] {
407 assert!(!truthy(off), "{off:?}");
408 }
409 }
410
411 #[test]
412 fn tail_keeps_the_last_lines() {
413 let tmp = temp("tail");
414 let home = tmp.path();
415 let p = home.join("log");
416 std::fs::write(&p, "a\nb\nc\n").unwrap();
417 assert_eq!(tail(&p, 2), "b\nc");
418 assert_eq!(tail(&p, 9), "a\nb\nc");
419 assert_eq!(tail(&home.join("missing"), 2), "");
420 }
421}