use crate::paths;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AliasError {
#[error(
"unknown session alias {alias:?} — not found in titles.tsv (use a UUID or a known title)"
)]
NotFound { alias: String },
#[error("failed to read titles.tsv at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
const DEFAULT_TITLE_INDEX_TTL: u64 = 300;
pub fn resolve_alias(name: &str) -> Result<String, AliasError> {
if looks_like_uuid(name) {
return Ok(name.to_owned());
}
let titles_path = paths::titles_tsv();
let ttl = read_ttl_from_env();
if !titles_path.exists() {
reindex_titles();
}
let stale = is_index_stale(&titles_path, ttl);
let sid = lookup_alias(name, &titles_path)?;
match sid {
Some(found) => {
if stale {
let tp = titles_path.clone();
std::thread::spawn(move || {
let _ = tp; reindex_titles();
});
}
Ok(found)
}
None => {
if stale {
reindex_titles();
match lookup_alias(name, &titles_path)? {
Some(found) => Ok(found),
None => Err(AliasError::NotFound {
alias: name.to_owned(),
}),
}
} else {
Err(AliasError::NotFound {
alias: name.to_owned(),
})
}
}
}
}
pub fn looks_like_uuid(s: &str) -> bool {
let b = s.as_bytes();
if b.len() != 36 {
return false;
}
if b[8] != b'-' || b[13] != b'-' || b[18] != b'-' || b[23] != b'-' {
return false;
}
for (i, &c) in b.iter().enumerate() {
if i == 8 || i == 13 || i == 18 || i == 23 {
continue;
}
if !c.is_ascii_hexdigit() {
return false;
}
}
true
}
fn lookup_alias(alias: &str, titles_path: &PathBuf) -> Result<Option<String>, AliasError> {
let content = match fs::read_to_string(titles_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(AliasError::Io {
path: titles_path.clone(),
source: e,
})
}
};
let mut best_mtime: i64 = -1;
let mut best_sid: Option<String> = None;
for line in content.lines() {
if line.starts_with('#') || line.is_empty() {
continue;
}
let mut cols = line.splitn(3, '\t');
let title = cols.next().unwrap_or("");
let sid = cols.next().unwrap_or("");
let mtime: i64 = cols.next().unwrap_or("0").trim().parse().unwrap_or(0);
if title == alias && !sid.is_empty() && mtime >= best_mtime {
best_mtime = mtime;
best_sid = Some(sid.to_owned());
}
}
Ok(best_sid)
}
pub fn reindex_titles() {
let projects_dir = paths::session_base_dir();
if !projects_dir.is_dir() {
return;
}
let _ = paths::smart_dir(); let titles_path = paths::titles_tsv();
let since_epoch: i64 = if titles_path.exists() {
file_mtime_epoch(&titles_path).unwrap_or(0)
} else {
0
};
let mut existing_rows: Vec<TitleRow> = if titles_path.exists() {
read_titles_tsv(&titles_path)
} else {
Vec::new()
};
let stale = collect_transcripts_newer_than(&projects_dir, since_epoch);
if stale.is_empty() && !existing_rows.is_empty() {
return; }
for tp in &stale {
let sid = match tp.file_stem().and_then(|s| s.to_str()) {
Some(s) if !s.is_empty() => s.to_owned(),
_ => continue,
};
let mtime = file_mtime_epoch(tp).unwrap_or(0);
if let Some(title) = extract_last_title(tp) {
existing_rows.push(TitleRow { title, sid, mtime });
}
}
let deduped = dedup_title_rows_by_sid(existing_rows);
let tmp_path = {
let mut p = titles_path.clone();
p.set_extension(format!("tmp{}", std::process::id()));
p
};
let mut content = String::new();
for row in &deduped {
content.push_str(&format!("{}\t{}\t{}\n", row.title, row.sid, row.mtime));
}
if fs::write(&tmp_path, &content).is_ok() {
let _ = fs::rename(&tmp_path, &titles_path).map_err(|_| {
let _ = fs::remove_file(&tmp_path);
});
}
}
fn collect_transcripts_newer_than(
projects_dir: &std::path::Path,
since_epoch: i64,
) -> Vec<PathBuf> {
let mut result = Vec::new();
let entries = match fs::read_dir(projects_dir) {
Ok(e) => e,
Err(_) => return result,
};
for entry in entries.flatten() {
let subdir = entry.path();
if !subdir.is_dir() {
continue;
}
let sub_entries = match fs::read_dir(&subdir) {
Ok(e) => e,
Err(_) => continue,
};
for sub_entry in sub_entries.flatten() {
let p = sub_entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if !p.is_file() {
continue;
}
let mt = file_mtime_epoch(&p).unwrap_or(0);
if since_epoch == 0 || mt > since_epoch {
result.push(p);
}
}
}
result
}
fn extract_last_title(path: &PathBuf) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let mut last_title: Option<String> = None;
for line in content.lines() {
if line.is_empty() {
continue;
}
let v: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let obj = match v.as_object() {
Some(o) => o,
None => continue,
};
let record_type = obj.get("type").and_then(|t| t.as_str()).unwrap_or("");
if record_type == "custom-title" || record_type == "agent-name" {
let title = obj
.get("customTitle")
.or_else(|| obj.get("agentName"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !title.is_empty() {
last_title = Some(title.to_owned());
}
}
}
last_title
}
struct TitleRow {
title: String,
sid: String,
mtime: i64,
}
fn read_titles_tsv(path: &PathBuf) -> Vec<TitleRow> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
content
.lines()
.filter(|l| !l.starts_with('#') && !l.is_empty())
.filter_map(|line| {
let mut cols = line.splitn(3, '\t');
let title = cols.next()?.to_owned();
let sid = cols.next()?.to_owned();
let mtime: i64 = cols.next()?.trim().parse().unwrap_or(0);
Some(TitleRow { title, sid, mtime })
})
.collect()
}
fn dedup_title_rows_by_sid(rows: Vec<TitleRow>) -> Vec<TitleRow> {
use std::collections::HashMap;
let mut map: HashMap<String, usize> = HashMap::new();
let mut out: Vec<TitleRow> = Vec::new();
for row in rows {
match map.get(&row.sid) {
None => {
map.insert(row.sid.clone(), out.len());
out.push(row);
}
Some(&idx) => {
if row.mtime >= out[idx].mtime {
out[idx] = row;
}
}
}
}
out
}
fn read_ttl_from_env() -> u64 {
std::env::var("CLAUDE_TITLE_INDEX_TTL")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_TITLE_INDEX_TTL)
}
fn is_index_stale(titles_path: &PathBuf, ttl: u64) -> bool {
let mtime = match file_mtime_epoch(titles_path) {
Some(m) => m,
None => return true, };
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs() as i64;
let age = now - mtime;
age > ttl as i64
}
fn file_mtime_epoch(path: &PathBuf) -> Option<i64> {
let mt = fs::metadata(path).ok()?.modified().ok()?;
let dur = mt.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO);
Some(dur.as_secs() as i64)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn uuid_recognised() {
assert!(looks_like_uuid("01234567-89ab-cdef-0123-456789abcdef"));
assert!(looks_like_uuid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
assert!(looks_like_uuid("00000000-0000-0000-0000-000000000000"));
assert!(looks_like_uuid("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"));
}
#[test]
fn non_uuid_rejected_by_length() {
assert!(!looks_like_uuid(""));
assert!(!looks_like_uuid("short"));
assert!(!looks_like_uuid("01234567-89ab-cdef-0123-456789abcde")); assert!(!looks_like_uuid("01234567-89ab-cdef-0123-456789abcdeff")); }
#[test]
fn non_uuid_rejected_by_missing_hyphens() {
assert!(!looks_like_uuid("01234567x89abxcdefx0123x456789abcdef"));
}
#[test]
fn non_uuid_rejected_by_non_hex_in_non_hyphen_position() {
assert!(!looks_like_uuid("g1234567-89ab-cdef-0123-456789abcdef"));
}
#[test]
fn non_uuid_rejected_when_hyphen_at_wrong_position() {
assert!(!looks_like_uuid("0123456-789ab-cdef-0123-456789abcdef"));
}
#[test]
fn human_readable_alias_not_uuid() {
assert!(!looks_like_uuid("my-cool-project"));
assert!(!looks_like_uuid("latest"));
assert!(!looks_like_uuid("some conversation about cats"));
}
#[test]
fn resolve_alias_passthrough_for_uuid() {
let uuid = "01234567-89ab-cdef-0123-456789abcdef";
let result = resolve_alias(uuid).expect("UUID should pass through without error");
assert_eq!(result, uuid);
}
#[test]
fn resolve_alias_passthrough_uppercase_uuid() {
let uuid = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE";
let result = resolve_alias(uuid).expect("uppercase UUID should pass through");
assert_eq!(result, uuid);
}
#[test]
fn lookup_alias_finds_exact_match() {
let tmp = TempDir::new().unwrap();
let titles_path = tmp.path().join("titles.tsv");
fs::write(
&titles_path,
"My Project\taaaaaaaa-0000-0000-0000-000000000001\t1000\n\
Other Title\tbbbbbbbb-0000-0000-0000-000000000002\t2000\n",
)
.unwrap();
let result = lookup_alias("My Project", &titles_path).expect("io");
assert_eq!(result.unwrap(), "aaaaaaaa-0000-0000-0000-000000000001");
}
#[test]
fn lookup_alias_returns_newest_mtime_on_duplicate_title() {
let tmp = TempDir::new().unwrap();
let titles_path = tmp.path().join("titles.tsv");
fs::write(
&titles_path,
"Same Title\told-sid-0000-0000-0000-000000000001\t100\n\
Same Title\tnew-sid-0000-0000-0000-000000000002\t999\n",
)
.unwrap();
let result = lookup_alias("Same Title", &titles_path).expect("io");
assert_eq!(result.unwrap(), "new-sid-0000-0000-0000-000000000002");
}
#[test]
fn lookup_alias_not_found_returns_none() {
let tmp = TempDir::new().unwrap();
let titles_path = tmp.path().join("titles.tsv");
fs::write(
&titles_path,
"Known Title\taaaaaaaa-0000-0000-0000-000000000001\t1000\n",
)
.unwrap();
let result = lookup_alias("Unknown Title", &titles_path).expect("io");
assert!(result.is_none());
}
#[test]
fn lookup_alias_ignores_comment_lines() {
let tmp = TempDir::new().unwrap();
let titles_path = tmp.path().join("titles.tsv");
fs::write(
&titles_path,
"# comment line\n\
Real Title\tcccccccc-0000-0000-0000-000000000001\t500\n",
)
.unwrap();
let result = lookup_alias("# comment line", &titles_path).expect("io");
assert!(result.is_none(), "comment lines must not match");
let result2 = lookup_alias("Real Title", &titles_path).expect("io");
assert_eq!(result2.unwrap(), "cccccccc-0000-0000-0000-000000000001");
}
#[test]
fn lookup_alias_ignores_blank_lines() {
let tmp = TempDir::new().unwrap();
let titles_path = tmp.path().join("titles.tsv");
fs::write(
&titles_path,
"\n\
Valid Title\tdddddddd-0000-0000-0000-000000000001\t500\n\
\n",
)
.unwrap();
let result = lookup_alias("Valid Title", &titles_path).expect("io");
assert_eq!(result.unwrap(), "dddddddd-0000-0000-0000-000000000001");
}
#[test]
fn lookup_alias_missing_file_returns_none() {
let tmp = TempDir::new().unwrap();
let titles_path = tmp.path().join("nonexistent-titles.tsv");
let result = lookup_alias("anything", &titles_path).expect("io");
assert!(result.is_none());
}
#[test]
fn extract_last_title_from_custom_title_record() {
let tmp = TempDir::new().unwrap();
let tp = tmp.path().join("test.jsonl");
fs::write(
&tp,
r#"{"type":"user","message":{"content":"hello"}}"#.to_owned()
+ "\n"
+ r#"{"type":"custom-title","customTitle":"My Custom Title"}"#
+ "\n",
)
.unwrap();
assert_eq!(extract_last_title(&tp).unwrap(), "My Custom Title");
}
#[test]
fn extract_last_title_from_agent_name_record() {
let tmp = TempDir::new().unwrap();
let tp = tmp.path().join("test.jsonl");
fs::write(
&tp,
r#"{"type":"agent-name","agentName":"My Agent"}"#.to_owned() + "\n",
)
.unwrap();
assert_eq!(extract_last_title(&tp).unwrap(), "My Agent");
}
#[test]
fn extract_last_title_takes_last_occurrence() {
let tmp = TempDir::new().unwrap();
let tp = tmp.path().join("test.jsonl");
fs::write(
&tp,
r#"{"type":"custom-title","customTitle":"First Title"}"#.to_owned()
+ "\n"
+ r#"{"type":"custom-title","customTitle":"Final Title"}"#
+ "\n",
)
.unwrap();
assert_eq!(extract_last_title(&tp).unwrap(), "Final Title");
}
#[test]
fn extract_last_title_no_title_record_returns_none() {
let tmp = TempDir::new().unwrap();
let tp = tmp.path().join("test.jsonl");
fs::write(
&tp,
r#"{"type":"user","message":{"content":"hello"}}"#.to_owned() + "\n",
)
.unwrap();
assert!(extract_last_title(&tp).is_none());
}
#[test]
fn extract_last_title_custom_title_wins_over_agent_name() {
let tmp = TempDir::new().unwrap();
let tp = tmp.path().join("test.jsonl");
fs::write(
&tp,
r#"{"type":"agent-name","agentName":"Agent"}"#.to_owned()
+ "\n"
+ r#"{"type":"custom-title","customTitle":"Custom"}"#
+ "\n",
)
.unwrap();
assert_eq!(extract_last_title(&tp).unwrap(), "Custom");
}
#[test]
fn dedup_title_rows_keeps_newest_mtime() {
let rows = vec![
TitleRow {
title: "Old Title".to_owned(),
sid: "aaa".to_owned(),
mtime: 100,
},
TitleRow {
title: "New Title".to_owned(),
sid: "aaa".to_owned(),
mtime: 200,
},
];
let deduped = dedup_title_rows_by_sid(rows);
assert_eq!(deduped.len(), 1);
assert_eq!(deduped[0].title, "New Title");
assert_eq!(deduped[0].mtime, 200);
}
#[test]
fn dedup_title_rows_equal_mtime_second_wins() {
let rows = vec![
TitleRow {
title: "First".to_owned(),
sid: "bbb".to_owned(),
mtime: 100,
},
TitleRow {
title: "Second".to_owned(),
sid: "bbb".to_owned(),
mtime: 100,
},
];
let deduped = dedup_title_rows_by_sid(rows);
assert_eq!(deduped.len(), 1);
assert_eq!(deduped[0].title, "Second"); }
#[test]
fn reindex_and_lookup_end_to_end() {
let tmp = TempDir::new().unwrap();
let titles_path = tmp.path().join("titles.tsv");
let sid = "ffffffff-0000-0000-0000-000000000001";
let row = TitleRow {
title: "Rust Port Design".to_owned(),
sid: sid.to_owned(),
mtime: 1_718_000_000,
};
let deduped = dedup_title_rows_by_sid(vec![row]);
let mut content = String::new();
for r in &deduped {
content.push_str(&format!("{}\t{}\t{}\n", r.title, r.sid, r.mtime));
}
fs::write(&titles_path, &content).unwrap();
let found = lookup_alias("Rust Port Design", &titles_path).expect("io");
assert_eq!(found.unwrap(), sid);
let missing = lookup_alias("Nonexistent Title", &titles_path).expect("io");
assert!(missing.is_none());
}
#[test]
fn alias_error_not_found_message_contains_alias() {
let err = AliasError::NotFound {
alias: "my-session".to_owned(),
};
let msg = err.to_string();
assert!(
msg.contains("my-session"),
"error message should contain the alias: {msg}"
);
}
#[test]
fn alias_error_io_message_contains_path() {
let err = AliasError::Io {
path: std::path::PathBuf::from("/some/path/titles.tsv"),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
};
let msg = err.to_string();
assert!(
msg.contains("titles.tsv"),
"error message should contain path: {msg}"
);
}
#[test]
fn is_index_stale_for_missing_file() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("no-such-file.tsv");
assert!(
is_index_stale(&missing, 300),
"missing file should be considered stale"
);
}
#[test]
fn is_index_stale_for_freshly_written_file() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("fresh.tsv");
fs::write(&p, "content\n").unwrap();
assert!(
!is_index_stale(&p, 300),
"freshly written file should not be stale"
);
}
}