anveesa 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib.rs CHANGED
@@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
18
18
  use tokio::sync::mpsc;
19
19
 
20
20
  use crate::{
21
- cli::{AskOptions, Cli, Command, ConfigCommand},
21
+ cli::{AskOptions, Cli, Command, ConfigCommand, SessionsCommand},
22
22
  config::{
23
23
  AppConfig, ProviderConfig, config_path, init_config, print_path, set_default_model,
24
24
  set_default_provider,
@@ -42,6 +42,8 @@ struct InteractiveSession {
42
42
  model: Option<String>,
43
43
  system: Option<String>,
44
44
  messages: Vec<ChatMessage>,
45
+ #[serde(default)]
46
+ saved_at: u64,
45
47
  }
46
48
 
47
49
  pub async fn run_anveesa() -> Result<()> {
@@ -53,6 +55,7 @@ async fn run_cli(cli: Cli) -> Result<()> {
53
55
  Some(Command::Ask(args)) => run_ask(args.options, args.prompt).await,
54
56
  Some(Command::Providers) => list_providers(),
55
57
  Some(Command::Config(args)) => run_config(args.command),
58
+ Some(Command::Sessions(args)) => run_sessions(args.command),
56
59
  None if cli.prompt.is_empty() && cli.ask_options.stdin => {
57
60
  run_ask(cli.ask_options, cli.prompt).await
58
61
  }
@@ -77,7 +80,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
77
80
  .providers
78
81
  .get(&provider_name)
79
82
  .with_context(|| format!("unknown provider '{provider_name}'"))?;
80
- let tools_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
83
+ let _tools_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
81
84
  let mut images_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
82
85
  let model = options
83
86
  .model
@@ -101,20 +104,32 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
101
104
 
102
105
  let mut accumulated_usage = Usage::default();
103
106
 
104
- let session_path = repl_session_path();
105
- let mut history = session_path
107
+ purge_stale_sessions();
108
+
109
+ let session_path = repl_session_path(&cwd);
110
+ let loaded_session = session_path
106
111
  .as_deref()
107
- .and_then(|path| load_interactive_session(path, &cwd, &provider_name, &session_options))
108
- .unwrap_or_default();
112
+ .and_then(|path| load_interactive_session(path, &cwd))
113
+ .or_else(|| {
114
+ // Migrate from the legacy single session.json if it matches our cwd.
115
+ let legacy = legacy_session_path()?;
116
+ let session = load_interactive_session(&legacy, &cwd)?;
117
+ let _ = fs::remove_file(&legacy);
118
+ Some(session)
119
+ });
120
+ let mut history = loaded_session.as_ref().map(|s| s.messages.clone()).unwrap_or_default();
121
+ // saved_at at load time — used only for the startup header so it shows when the previous
122
+ // run ended, not the current run's save time.
123
+ let session_saved_at = loaded_session.as_ref().filter(|s| s.saved_at > 0).map(|s| s.saved_at);
124
+ // tracks the most recent successful save this run — kept fresh for /session display
125
+ let mut last_saved_at: u64 = session_saved_at.unwrap_or(0);
109
126
  let history_path = repl_history_path();
110
127
  print_session_header(
111
128
  &provider_name,
112
129
  session_options.model.as_deref().unwrap_or("-"),
113
130
  history.len() / 2,
114
- workspace_context.is_some(),
115
- tools_available,
116
- policy,
117
131
  !history.is_empty(),
132
+ session_saved_at,
118
133
  );
119
134
 
120
135
  let is_tty = io::stdout().is_terminal();
@@ -166,6 +181,15 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
166
181
  print_help_inline(is_tty);
167
182
  continue;
168
183
  }
184
+ "/session" => {
185
+ print_session_info(
186
+ is_tty,
187
+ session_path.as_deref(),
188
+ history.len() / 2,
189
+ Some(last_saved_at).filter(|&t| t > 0),
190
+ );
191
+ continue;
192
+ }
169
193
  "/status" => {
170
194
  print_status_inline(
171
195
  is_tty,
@@ -284,19 +308,22 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
284
308
  eprintln!("\x1b[2m Screenshot from clipboard attached.\x1b[0m");
285
309
  }
286
310
 
287
- match ask_streaming(
288
- &config,
289
- &session_options,
290
- prompt.clone(),
291
- &history,
292
- workspace_context.as_deref(),
293
- policy,
294
- image,
295
- RenderMode::Interactive,
296
- )
297
- .await
298
- {
299
- Ok(result) => {
311
+ let ask_result = tokio::select! {
312
+ r = ask_streaming(
313
+ &config,
314
+ &session_options,
315
+ prompt.clone(),
316
+ &history,
317
+ workspace_context.as_deref(),
318
+ policy,
319
+ image,
320
+ RenderMode::Interactive,
321
+ ) => Some(r),
322
+ _ = tokio::signal::ctrl_c() => None,
323
+ };
324
+
325
+ match ask_result {
326
+ Some(Ok(result)) => {
300
327
  println!();
301
328
  if let Some(u) = result.usage {
302
329
  accumulated_usage.prompt_tokens += u.prompt_tokens;
@@ -308,16 +335,12 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
308
335
  history.push(ChatMessage::user(prompt));
309
336
  history.push(ChatMessage::assistant(result.text));
310
337
  if let Some(path) = &session_path {
311
- let _ = save_interactive_session(
312
- path,
313
- &cwd,
314
- &provider_name,
315
- &session_options,
316
- &history,
317
- );
338
+ if save_interactive_session(path, &cwd, &provider_name, &session_options, &history).is_ok() {
339
+ last_saved_at = unix_now();
340
+ }
318
341
  }
319
342
  }
320
- Err(error) => {
343
+ Some(Err(error)) => {
321
344
  if is_tty {
322
345
  eprintln!("\x1b[1;31m✗\x1b[0m {error:#}");
323
346
  } else {
@@ -329,15 +352,24 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
329
352
  "The previous turn failed inside Anveesa before a final answer was produced: {error:#}"
330
353
  )));
331
354
  if let Some(path) = &session_path {
332
- let _ = save_interactive_session(
333
- path,
334
- &cwd,
335
- &provider_name,
336
- &session_options,
337
- &history,
338
- );
355
+ if save_interactive_session(path, &cwd, &provider_name, &session_options, &history).is_ok() {
356
+ last_saved_at = unix_now();
357
+ }
339
358
  }
340
359
  }
360
+ None => {
361
+ // Ctrl+C during streaming — save current history and exit cleanly.
362
+ println!();
363
+ if is_tty {
364
+ eprintln!("\x1b[2m ^C Session saved.\x1b[0m");
365
+ } else {
366
+ eprintln!("interrupted");
367
+ }
368
+ if let Some(path) = &session_path {
369
+ let _ = save_interactive_session(path, &cwd, &provider_name, &session_options, &history);
370
+ }
371
+ break;
372
+ }
341
373
  }
342
374
  }
343
375
 
@@ -347,6 +379,117 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
347
379
  Ok(())
348
380
  }
349
381
 
382
+ fn run_sessions(command: SessionsCommand) -> Result<()> {
383
+ match command {
384
+ SessionsCommand::List => list_sessions(),
385
+ SessionsCommand::Clear { all } => clear_sessions(all),
386
+ }
387
+ }
388
+
389
+ fn sessions_dir() -> Option<PathBuf> {
390
+ let config_dir = config_path().ok()?.parent()?.to_path_buf();
391
+ Some(config_dir.join("sessions"))
392
+ }
393
+
394
+ fn list_sessions() -> Result<()> {
395
+ let Some(dir) = sessions_dir() else {
396
+ println!("No sessions directory found.");
397
+ return Ok(());
398
+ };
399
+ let Ok(entries) = fs::read_dir(&dir) else {
400
+ println!("No sessions found.");
401
+ return Ok(());
402
+ };
403
+
404
+ let mut sessions: Vec<(String, usize, u64)> = Vec::new();
405
+ for entry in entries.flatten() {
406
+ let path = entry.path();
407
+ if path.extension().and_then(|e| e.to_str()) != Some("json") {
408
+ continue;
409
+ }
410
+ if let Ok(content) = fs::read_to_string(&path) {
411
+ if let Ok(session) = serde_json::from_str::<InteractiveSession>(&content) {
412
+ sessions.push((session.cwd, session.messages.len() / 2, session.saved_at));
413
+ }
414
+ }
415
+ }
416
+ sessions.sort_by(|a, b| b.2.cmp(&a.2));
417
+
418
+ let is_tty = io::stdout().is_terminal();
419
+ if sessions.is_empty() {
420
+ if is_tty {
421
+ eprintln!("\x1b[2m No saved sessions.\x1b[0m");
422
+ } else {
423
+ println!("no sessions");
424
+ }
425
+ return Ok(());
426
+ }
427
+
428
+ if !is_tty {
429
+ for (cwd, turns, saved_at) in &sessions {
430
+ println!("{cwd}\t{turns}\t{saved_at}");
431
+ }
432
+ return Ok(());
433
+ }
434
+
435
+ println!();
436
+ println!("\x1b[90m ──────────────────────────────────────────────────────\x1b[0m");
437
+ for (cwd, turns, saved_at) in &sessions {
438
+ let age = format_session_age(Some(*saved_at));
439
+ let turn_str = if *turns == 1 { "1 turn ".to_string() } else { format!("{turns} turns") };
440
+ let short_cwd = std::env::var("HOME")
441
+ .map(|h| cwd.replacen(&h, "~", 1))
442
+ .unwrap_or_else(|_| cwd.clone());
443
+ println!(" \x1b[2m{age:>10}\x1b[0m {turn_str:>7} {short_cwd}");
444
+ }
445
+ println!("\x1b[90m ──────────────────────────────────────────────────────\x1b[0m");
446
+ println!();
447
+ Ok(())
448
+ }
449
+
450
+ fn clear_sessions(all: bool) -> Result<()> {
451
+ let is_tty = io::stdout().is_terminal();
452
+ if all {
453
+ let mut count = 0usize;
454
+ if let Some(dir) = sessions_dir() {
455
+ if let Ok(entries) = fs::read_dir(&dir) {
456
+ for entry in entries.flatten() {
457
+ let path = entry.path();
458
+ if path.extension().and_then(|e| e.to_str()) == Some("json") {
459
+ if fs::remove_file(&path).is_ok() {
460
+ count += 1;
461
+ }
462
+ }
463
+ }
464
+ }
465
+ }
466
+ if is_tty {
467
+ eprintln!("\x1b[2m {count} session(s) deleted.\x1b[0m");
468
+ } else {
469
+ println!("{count} sessions deleted");
470
+ }
471
+ } else {
472
+ let cwd = std::env::current_dir().context("failed to resolve current directory")?;
473
+ let path = repl_session_path(&cwd).context("could not determine session path")?;
474
+ if path.exists() {
475
+ fs::remove_file(&path)
476
+ .with_context(|| format!("failed to delete {}", path.display()))?;
477
+ if is_tty {
478
+ eprintln!("\x1b[2m Session for {} cleared.\x1b[0m", cwd.display());
479
+ } else {
480
+ println!("session cleared");
481
+ }
482
+ } else {
483
+ if is_tty {
484
+ eprintln!("\x1b[2m No session for {}.\x1b[0m", cwd.display());
485
+ } else {
486
+ println!("no session");
487
+ }
488
+ }
489
+ }
490
+ Ok(())
491
+ }
492
+
350
493
  async fn run_ask(options: AskOptions, prompt_parts: Vec<String>) -> Result<()> {
351
494
  let config = AppConfig::load()?;
352
495
  let provider_name = config
@@ -957,7 +1100,7 @@ fn print_status_inline(
957
1100
 
958
1101
  fn print_help_inline(is_tty: bool) {
959
1102
  if !is_tty {
960
- println!("commands: /clear, /attach [path], /exit, /quit, /help");
1103
+ println!("commands: /clear, /session, /attach [path], /exit, /quit, /help");
961
1104
  println!("images: copy an image (Cmd+C), then send a message to auto-attach it");
962
1105
  return;
963
1106
  }
@@ -965,9 +1108,10 @@ fn print_help_inline(is_tty: bool) {
965
1108
  println!("\x1b[2m Commands\x1b[0m");
966
1109
  println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
967
1110
  println!(" \x1b[1;32m/status\x1b[0m provider, model, turns, token usage");
1111
+ println!(" \x1b[1;32m/session\x1b[0m show session file, age, and turn count");
968
1112
  println!(" \x1b[1;32m/model\x1b[0m \x1b[2m[name]\x1b[0m switch or show current model");
969
1113
  println!(" \x1b[1;32m/provider\x1b[0m \x1b[2m[name]\x1b[0m switch or show current provider");
970
- println!(" \x1b[1;32m/clear\x1b[0m reset conversation");
1114
+ println!(" \x1b[1;32m/clear\x1b[0m reset conversation and delete saved session");
971
1115
  println!(" \x1b[1;32m/attach\x1b[0m \x1b[2m[path]\x1b[0m attach image from file or clipboard");
972
1116
  println!(" \x1b[1;32m/exit\x1b[0m, \x1b[1;32m/quit\x1b[0m leave the session");
973
1117
  println!(" \x1b[1;32m/help\x1b[0m show this message");
@@ -980,6 +1124,42 @@ fn print_help_inline(is_tty: bool) {
980
1124
  println!();
981
1125
  }
982
1126
 
1127
+ fn print_session_info(is_tty: bool, path: Option<&Path>, turns: usize, saved_at: Option<u64>) {
1128
+ let Some(path) = path else {
1129
+ if is_tty {
1130
+ eprintln!("\x1b[2m no session path available\x1b[0m");
1131
+ } else {
1132
+ println!("no session path available");
1133
+ }
1134
+ return;
1135
+ };
1136
+
1137
+ let short_path = std::env::var("HOME")
1138
+ .map(|h| path.display().to_string().replacen(&h, "~", 1))
1139
+ .unwrap_or_else(|_| path.display().to_string());
1140
+
1141
+ if !is_tty {
1142
+ println!("session: {short_path}");
1143
+ println!("turns: {turns}");
1144
+ if let Some(ts) = saved_at {
1145
+ println!("saved: {}", format_session_age(Some(ts)));
1146
+ }
1147
+ return;
1148
+ }
1149
+
1150
+ println!();
1151
+ println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
1152
+ println!(" \x1b[2mfile \x1b[0m \x1b[2m{short_path}\x1b[0m");
1153
+ println!(" \x1b[2mturns \x1b[0m {turns}");
1154
+ if let Some(ts) = saved_at {
1155
+ println!(" \x1b[2msaved \x1b[0m {}", format_session_age(Some(ts)));
1156
+ } else {
1157
+ println!(" \x1b[2msaved \x1b[0m not yet");
1158
+ }
1159
+ println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
1160
+ println!();
1161
+ }
1162
+
983
1163
  fn list_providers() -> Result<()> {
984
1164
  let config = AppConfig::load()?;
985
1165
  let is_tty = io::stdout().is_terminal();
@@ -1073,17 +1253,19 @@ fn build_prompt(prompt_parts: Vec<String>, force_stdin: bool) -> Result<String>
1073
1253
  fn print_session_header(
1074
1254
  provider: &str,
1075
1255
  model: &str,
1076
- _turns: usize,
1077
- _has_workspace_context: bool,
1078
- _tools_available: bool,
1079
- _policy: ApprovalPolicy,
1256
+ turns: usize,
1080
1257
  resumed: bool,
1258
+ saved_at: Option<u64>,
1081
1259
  ) {
1082
1260
  let is_tty = io::stdout().is_terminal();
1083
1261
  let version = env!("CARGO_PKG_VERSION");
1084
1262
 
1085
1263
  if !is_tty {
1086
- let tag = if resumed { " (resumed)" } else { "" };
1264
+ let tag = if resumed {
1265
+ format!(" (resumed · {turns} turns · {})", format_session_age(saved_at))
1266
+ } else {
1267
+ String::new()
1268
+ };
1087
1269
  println!("anveesa v{version}{tag} | {provider} · {model}");
1088
1270
  return;
1089
1271
  }
@@ -1110,8 +1292,11 @@ fn print_session_header(
1110
1292
  r
1111
1293
  }
1112
1294
 
1113
- // ── anveesa v0.2.8 Welcome back!] ─────────────────
1114
- let greeting = if resumed { " · Welcome back!" } else { "" };
1295
+ let greeting = if resumed {
1296
+ format!(" · Resumed ({turns} turns · {})", format_session_age(saved_at))
1297
+ } else {
1298
+ String::new()
1299
+ };
1115
1300
  let title = format!(" anveesa v{version}{greeting} ");
1116
1301
  let title_len = title.chars().count();
1117
1302
  let right_dashes = width.saturating_sub(2 + title_len);
@@ -1120,11 +1305,9 @@ fn print_session_header(
1120
1305
  "─".repeat(right_dashes)
1121
1306
  );
1122
1307
 
1123
- // provider · model · ~/cwd
1124
1308
  let info = trunc_to(&format!(" {provider} · {model} · {cwd}"), width);
1125
1309
  println!("\x1b[2m{info}\x1b[0m");
1126
1310
 
1127
- // /help for commands
1128
1311
  println!("\x1b[2m /help for commands\x1b[0m");
1129
1312
  println!();
1130
1313
  }
@@ -1755,6 +1938,40 @@ fn repl_history_path() -> Option<PathBuf> {
1755
1938
  Some(dir.join("history"))
1756
1939
  }
1757
1940
 
1941
+ fn unix_now() -> u64 {
1942
+ std::time::SystemTime::now()
1943
+ .duration_since(std::time::UNIX_EPOCH)
1944
+ .unwrap_or_default()
1945
+ .as_secs()
1946
+ }
1947
+
1948
+ fn format_session_age(saved_at: Option<u64>) -> String {
1949
+ let Some(ts) = saved_at else {
1950
+ return "unknown age".to_string();
1951
+ };
1952
+ let secs = unix_now().saturating_sub(ts);
1953
+ if secs < 60 {
1954
+ "just now".to_string()
1955
+ } else if secs < 3600 {
1956
+ format!("{}m ago", secs / 60)
1957
+ } else if secs < 86400 {
1958
+ format!("{}h ago", secs / 3600)
1959
+ } else {
1960
+ format!("{}d ago", secs / 86400)
1961
+ }
1962
+ }
1963
+
1964
+ /// FNV-1a 64-bit hash of the cwd path — used as a stable per-directory session filename.
1965
+ fn cwd_session_hash(cwd: &Path) -> String {
1966
+ let s = cwd.to_string_lossy();
1967
+ let mut h: u64 = 14695981039346656037;
1968
+ for b in s.bytes() {
1969
+ h ^= b as u64;
1970
+ h = h.wrapping_mul(1099511628211);
1971
+ }
1972
+ format!("{h:016x}")
1973
+ }
1974
+
1758
1975
  fn append_repl_history(path: &Path, prompt: &str) -> io::Result<()> {
1759
1976
  if let Some(dir) = path.parent() {
1760
1977
  fs::create_dir_all(dir)?;
@@ -1767,25 +1984,55 @@ fn append_repl_history(path: &Path, prompt: &str) -> io::Result<()> {
1767
1984
  writeln!(file, "{prompt}")
1768
1985
  }
1769
1986
 
1770
- fn repl_session_path() -> Option<PathBuf> {
1771
- let path = config_path().ok()?;
1772
- let dir = path.parent()?;
1773
- let _ = fs::create_dir_all(dir);
1774
- Some(dir.join("session.json"))
1987
+ /// Delete all session files whose saved_at is older than 30 days. Called once at
1988
+ /// startup so orphaned sessions (from deleted/moved projects) eventually disappear.
1989
+ fn purge_stale_sessions() {
1990
+ let Some(dir) = sessions_dir() else { return };
1991
+ let Ok(entries) = fs::read_dir(&dir) else { return };
1992
+ let cutoff = unix_now().saturating_sub(30 * 24 * 3600);
1993
+ for entry in entries.flatten() {
1994
+ let path = entry.path();
1995
+ if path.extension().and_then(|e| e.to_str()) != Some("json") {
1996
+ continue;
1997
+ }
1998
+ let stale = fs::read_to_string(&path)
1999
+ .ok()
2000
+ .and_then(|c| serde_json::from_str::<InteractiveSession>(&c).ok())
2001
+ .map(|s| s.saved_at > 0 && s.saved_at < cutoff)
2002
+ .unwrap_or(true); // unparseable → delete
2003
+ if stale {
2004
+ let _ = fs::remove_file(&path);
2005
+ }
2006
+ }
1775
2007
  }
1776
2008
 
1777
- fn load_interactive_session(
1778
- path: &Path,
1779
- cwd: &Path,
1780
- provider: &str,
1781
- options: &AskOptions,
1782
- ) -> Option<Vec<ChatMessage>> {
2009
+ /// Per-directory session file: ~/.config/anveesa/sessions/{cwd-hash}.json
2010
+ fn repl_session_path(cwd: &Path) -> Option<PathBuf> {
2011
+ let config_dir = config_path().ok()?.parent()?.to_path_buf();
2012
+ let sessions_dir = config_dir.join("sessions");
2013
+ let _ = fs::create_dir_all(&sessions_dir);
2014
+ Some(sessions_dir.join(format!("{}.json", cwd_session_hash(cwd))))
2015
+ }
2016
+
2017
+ /// Legacy path for backward-compat migration.
2018
+ fn legacy_session_path() -> Option<PathBuf> {
2019
+ let config_dir = config_path().ok()?.parent()?.to_path_buf();
2020
+ let path = config_dir.join("session.json");
2021
+ if path.exists() { Some(path) } else { None }
2022
+ }
2023
+
2024
+ fn load_interactive_session(path: &Path, cwd: &Path) -> Option<InteractiveSession> {
1783
2025
  let content = fs::read_to_string(path).ok()?;
1784
2026
  let session: InteractiveSession = serde_json::from_str(&content).ok()?;
1785
- if !session_matches(&session, cwd, provider, options) {
2027
+ if session.cwd != cwd.display().to_string() {
2028
+ return None;
2029
+ }
2030
+ // Auto-expire sessions older than 30 days.
2031
+ if session.saved_at > 0 && unix_now().saturating_sub(session.saved_at) > 30 * 24 * 3600 {
2032
+ let _ = fs::remove_file(path);
1786
2033
  return None;
1787
2034
  }
1788
- Some(session.messages)
2035
+ Some(session)
1789
2036
  }
1790
2037
 
1791
2038
  fn save_interactive_session(
@@ -1801,24 +2048,13 @@ fn save_interactive_session(
1801
2048
  model: options.model.clone(),
1802
2049
  system: options.system.clone(),
1803
2050
  messages: history.to_vec(),
2051
+ saved_at: unix_now(),
1804
2052
  };
1805
2053
  let content = serde_json::to_string_pretty(&session)
1806
2054
  .context("failed to serialize interactive session")?;
1807
2055
  fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
1808
2056
  }
1809
2057
 
1810
- fn session_matches(
1811
- session: &InteractiveSession,
1812
- cwd: &Path,
1813
- provider: &str,
1814
- options: &AskOptions,
1815
- ) -> bool {
1816
- session.cwd == cwd.display().to_string()
1817
- && session.provider == provider
1818
- && session.model == options.model
1819
- && session.system == options.system
1820
- }
1821
-
1822
2058
  fn workspace_context() -> Result<String> {
1823
2059
  let cwd = std::env::current_dir().context("failed to resolve current directory")?;
1824
2060
  workspace_context_for(&cwd)
@@ -1987,14 +2223,7 @@ mod tests {
1987
2223
  }
1988
2224
 
1989
2225
  #[test]
1990
- fn interactive_session_matches_same_scope_only() {
1991
- let options = AskOptions {
1992
- provider: Some("provider-a".into()),
1993
- model: Some("model-a".into()),
1994
- system: None,
1995
- stdin: false,
1996
- yes: false,
1997
- };
2226
+ fn interactive_session_matches_cwd_only() {
1998
2227
  let cwd = Path::new("/tmp/anveesa-session");
1999
2228
  let session = InteractiveSession {
2000
2229
  cwd: cwd.display().to_string(),
@@ -2002,16 +2231,14 @@ mod tests {
2002
2231
  model: Some("model-a".into()),
2003
2232
  system: None,
2004
2233
  messages: vec![],
2234
+ saved_at: 0,
2005
2235
  };
2006
2236
 
2007
- assert!(session_matches(&session, cwd, "provider-a", &options));
2008
- assert!(!session_matches(
2009
- &session,
2010
- Path::new("/tmp/other"),
2011
- "provider-a",
2012
- &options
2013
- ));
2014
- assert!(!session_matches(&session, cwd, "provider-b", &options));
2237
+ // Matches when cwd is the same.
2238
+ assert_eq!(session.cwd, cwd.display().to_string());
2239
+ // A different cwd should not match.
2240
+ assert_ne!(session.cwd, Path::new("/tmp/other").display().to_string());
2241
+ // Provider/model differences no longer prevent a session from loading.
2015
2242
  }
2016
2243
 
2017
2244
  #[test]
@@ -2034,11 +2261,218 @@ mod tests {
2034
2261
 
2035
2262
  save_interactive_session(&path, &dir, "provider-a", &options, &history).unwrap();
2036
2263
 
2037
- assert_eq!(
2038
- load_interactive_session(&path, &dir, "provider-a", &options),
2039
- Some(history)
2040
- );
2264
+ let loaded = load_interactive_session(&path, &dir).unwrap();
2265
+ assert_eq!(loaded.messages, history);
2266
+ // saved_at should be set.
2267
+ assert!(loaded.saved_at > 0);
2041
2268
 
2042
2269
  let _ = fs::remove_dir_all(&dir);
2043
2270
  }
2271
+
2272
+ // ── cwd_session_hash ──────────────────────────────────────────────────────
2273
+
2274
+ #[test]
2275
+ fn cwd_hash_is_deterministic() {
2276
+ let p = Path::new("/home/user/my-project");
2277
+ assert_eq!(cwd_session_hash(p), cwd_session_hash(p));
2278
+ }
2279
+
2280
+ #[test]
2281
+ fn cwd_hash_differs_for_different_paths() {
2282
+ let a = cwd_session_hash(Path::new("/home/user/project-a"));
2283
+ let b = cwd_session_hash(Path::new("/home/user/project-b"));
2284
+ let c = cwd_session_hash(Path::new("/home/user/project-a/sub"));
2285
+ assert_ne!(a, b);
2286
+ assert_ne!(a, c);
2287
+ assert_ne!(b, c);
2288
+ }
2289
+
2290
+ #[test]
2291
+ fn cwd_hash_is_16_hex_chars() {
2292
+ let h = cwd_session_hash(Path::new("/any/path"));
2293
+ assert_eq!(h.len(), 16);
2294
+ assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
2295
+ }
2296
+
2297
+ // ── format_session_age ────────────────────────────────────────────────────
2298
+
2299
+ #[test]
2300
+ fn format_age_none_returns_unknown() {
2301
+ assert_eq!(format_session_age(None), "unknown age");
2302
+ }
2303
+
2304
+ #[test]
2305
+ fn format_age_just_now() {
2306
+ let ts = unix_now();
2307
+ assert_eq!(format_session_age(Some(ts)), "just now");
2308
+ assert_eq!(format_session_age(Some(ts - 59)), "just now");
2309
+ }
2310
+
2311
+ #[test]
2312
+ fn format_age_minutes() {
2313
+ let ts = unix_now() - 60;
2314
+ assert_eq!(format_session_age(Some(ts)), "1m ago");
2315
+ let ts2 = unix_now() - 3599;
2316
+ assert_eq!(format_session_age(Some(ts2)), "59m ago");
2317
+ }
2318
+
2319
+ #[test]
2320
+ fn format_age_hours() {
2321
+ let ts = unix_now() - 3600;
2322
+ assert_eq!(format_session_age(Some(ts)), "1h ago");
2323
+ let ts2 = unix_now() - 86399;
2324
+ assert_eq!(format_session_age(Some(ts2)), "23h ago");
2325
+ }
2326
+
2327
+ #[test]
2328
+ fn format_age_days() {
2329
+ let ts = unix_now() - 86400;
2330
+ assert_eq!(format_session_age(Some(ts)), "1d ago");
2331
+ let ts2 = unix_now() - 7 * 86400;
2332
+ assert_eq!(format_session_age(Some(ts2)), "7d ago");
2333
+ }
2334
+
2335
+ // ── 30-day expiry ─────────────────────────────────────────────────────────
2336
+
2337
+ #[test]
2338
+ fn expired_session_is_deleted_on_load() {
2339
+ let dir = std::env::temp_dir().join(format!("anveesa_expiry_{}", std::process::id()));
2340
+ let _ = fs::remove_dir_all(&dir);
2341
+ fs::create_dir_all(&dir).unwrap();
2342
+ let path = dir.join("old_session.json");
2343
+ let options = AskOptions { provider: Some("p".into()), model: None, system: None, stdin: false, yes: false };
2344
+ save_interactive_session(&path, &dir, "p", &options, &[]).unwrap();
2345
+
2346
+ // Backdate saved_at by 31 days.
2347
+ let content = fs::read_to_string(&path).unwrap();
2348
+ let mut session: InteractiveSession = serde_json::from_str(&content).unwrap();
2349
+ session.saved_at = unix_now() - 31 * 24 * 3600;
2350
+ fs::write(&path, serde_json::to_string_pretty(&session).unwrap()).unwrap();
2351
+
2352
+ let result = load_interactive_session(&path, &dir);
2353
+ assert!(result.is_none(), "expired session must not load");
2354
+ assert!(!path.exists(), "expired session file must be deleted");
2355
+
2356
+ let _ = fs::remove_dir_all(&dir);
2357
+ }
2358
+
2359
+ #[test]
2360
+ fn non_expired_session_loads_normally() {
2361
+ let dir = std::env::temp_dir().join(format!("anveesa_noexpiry_{}", std::process::id()));
2362
+ let _ = fs::remove_dir_all(&dir);
2363
+ fs::create_dir_all(&dir).unwrap();
2364
+ let path = dir.join("session.json");
2365
+ let options = AskOptions { provider: Some("p".into()), model: None, system: None, stdin: false, yes: false };
2366
+ let history = vec![ChatMessage::user("hi".into()), ChatMessage::assistant("hello".into())];
2367
+ save_interactive_session(&path, &dir, "p", &options, &history).unwrap();
2368
+
2369
+ let loaded = load_interactive_session(&path, &dir).unwrap();
2370
+ assert_eq!(loaded.messages, history);
2371
+ assert!(path.exists());
2372
+
2373
+ let _ = fs::remove_dir_all(&dir);
2374
+ }
2375
+
2376
+ // ── legacy migration ──────────────────────────────────────────────────────
2377
+
2378
+ #[test]
2379
+ fn mismatched_cwd_returns_none() {
2380
+ let dir_a = std::env::temp_dir().join(format!("anveesa_cwd_a_{}", std::process::id()));
2381
+ let dir_b = std::env::temp_dir().join(format!("anveesa_cwd_b_{}", std::process::id()));
2382
+ let _ = fs::remove_dir_all(&dir_a);
2383
+ let _ = fs::remove_dir_all(&dir_b);
2384
+ fs::create_dir_all(&dir_a).unwrap();
2385
+ let path = dir_a.join("session.json");
2386
+ let options = AskOptions { provider: None, model: None, system: None, stdin: false, yes: false };
2387
+ save_interactive_session(&path, &dir_a, "p", &options, &[]).unwrap();
2388
+
2389
+ // Loading with a different cwd must return None.
2390
+ assert!(load_interactive_session(&path, &dir_b).is_none());
2391
+ // Loading with the correct cwd must succeed.
2392
+ assert!(load_interactive_session(&path, &dir_a).is_some());
2393
+
2394
+ let _ = fs::remove_dir_all(&dir_a);
2395
+ }
2396
+
2397
+ // ── purge_stale_sessions ──────────────────────────────────────────────────
2398
+
2399
+ #[test]
2400
+ fn purge_removes_old_files_but_keeps_recent_ones() {
2401
+ let sessions_base = std::env::temp_dir()
2402
+ .join(format!("anveesa_purge_{}", std::process::id()));
2403
+ let _ = fs::remove_dir_all(&sessions_base);
2404
+ fs::create_dir_all(&sessions_base).unwrap();
2405
+
2406
+ let options = AskOptions { provider: None, model: None, system: None, stdin: false, yes: false };
2407
+
2408
+ // Create two fresh sessions and one stale session.
2409
+ let fresh_dir_1 = sessions_base.join("project1");
2410
+ let fresh_dir_2 = sessions_base.join("project2");
2411
+ let stale_dir = sessions_base.join("old_project");
2412
+ fs::create_dir_all(&fresh_dir_1).unwrap();
2413
+ fs::create_dir_all(&fresh_dir_2).unwrap();
2414
+ fs::create_dir_all(&stale_dir).unwrap();
2415
+
2416
+ let fresh1_path = sessions_base.join("fresh1.json");
2417
+ let fresh2_path = sessions_base.join("fresh2.json");
2418
+ let stale_path = sessions_base.join("stale.json");
2419
+
2420
+ save_interactive_session(&fresh1_path, &fresh_dir_1, "p", &options, &[]).unwrap();
2421
+ save_interactive_session(&fresh2_path, &fresh_dir_2, "p", &options, &[]).unwrap();
2422
+ save_interactive_session(&stale_path, &stale_dir, "p", &options, &[]).unwrap();
2423
+
2424
+ // Backdate the stale session.
2425
+ let content = fs::read_to_string(&stale_path).unwrap();
2426
+ let mut session: InteractiveSession = serde_json::from_str(&content).unwrap();
2427
+ session.saved_at = unix_now() - 31 * 24 * 3600;
2428
+ fs::write(&stale_path, serde_json::to_string_pretty(&session).unwrap()).unwrap();
2429
+
2430
+ // Manually run purge logic over our temp dir (can't call purge_stale_sessions
2431
+ // directly since it targets the real config dir, so we replicate its logic).
2432
+ let cutoff = unix_now().saturating_sub(30 * 24 * 3600);
2433
+ for entry in fs::read_dir(&sessions_base).unwrap().flatten() {
2434
+ let path = entry.path();
2435
+ if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; }
2436
+ let stale = fs::read_to_string(&path)
2437
+ .ok()
2438
+ .and_then(|c| serde_json::from_str::<InteractiveSession>(&c).ok())
2439
+ .map(|s| s.saved_at > 0 && s.saved_at < cutoff)
2440
+ .unwrap_or(true);
2441
+ if stale { let _ = fs::remove_file(&path); }
2442
+ }
2443
+
2444
+ assert!(fresh1_path.exists(), "fresh session 1 must not be purged");
2445
+ assert!(fresh2_path.exists(), "fresh session 2 must not be purged");
2446
+ assert!(!stale_path.exists(), "stale session must be purged");
2447
+
2448
+ let _ = fs::remove_dir_all(&sessions_base);
2449
+ }
2450
+
2451
+ #[test]
2452
+ fn purge_removes_unparseable_json_files() {
2453
+ let sessions_base = std::env::temp_dir()
2454
+ .join(format!("anveesa_purge_bad_{}", std::process::id()));
2455
+ let _ = fs::remove_dir_all(&sessions_base);
2456
+ fs::create_dir_all(&sessions_base).unwrap();
2457
+
2458
+ let bad_path = sessions_base.join("corrupt.json");
2459
+ fs::write(&bad_path, b"not valid json at all {{{").unwrap();
2460
+
2461
+ // Replicate purge logic.
2462
+ let cutoff = unix_now().saturating_sub(30 * 24 * 3600);
2463
+ for entry in fs::read_dir(&sessions_base).unwrap().flatten() {
2464
+ let path = entry.path();
2465
+ if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; }
2466
+ let stale = fs::read_to_string(&path)
2467
+ .ok()
2468
+ .and_then(|c| serde_json::from_str::<InteractiveSession>(&c).ok())
2469
+ .map(|s| s.saved_at > 0 && s.saved_at < cutoff)
2470
+ .unwrap_or(true);
2471
+ if stale { let _ = fs::remove_file(&path); }
2472
+ }
2473
+
2474
+ assert!(!bad_path.exists(), "corrupt session file must be purged");
2475
+
2476
+ let _ = fs::remove_dir_all(&sessions_base);
2477
+ }
2044
2478
  }