use std::collections::HashMap;
use std::path::Path;
use chrono::{DateTime, Utc};
use rusqlite::{Connection, params};
use crate::schema::{EffectDecl, FeedbackEntry, FeedbackVerdict, LedgerEntry, Symbol};
pub const STOPWORDS: &[&str] = &[
"a", "an", "and", "as", "at", "be", "but", "by", "for", "from", "if", "into", "is", "it",
"nor", "not", "of", "on", "or", "so", "the", "to", "via", "vs", "yet", "with", "over", "about",
"between", "than", "that", "this", "are", "was", "were", "has", "have", "had", "its", "our",
];
#[inline]
pub fn is_stopword(token: &str) -> bool {
STOPWORDS.contains(&token)
}
pub type SymbolTier = u8;
#[derive(Debug, Clone)]
pub struct FtsHit {
pub bm25_score: f64,
pub symbol_id: String,
pub qname: String,
pub kind: String,
pub language: String,
pub file: String,
pub line: u32,
pub signature: Option<String>,
pub doc: Option<String>,
pub tier: SymbolTier,
pub ledger_text: String,
pub ledger_flags: String,
}
impl FtsHit {
#[inline]
pub fn has_ownership(&self) -> bool {
self.ledger_flags.contains("ownership")
}
#[inline]
pub fn has_invariant(&self) -> bool {
self.ledger_flags.contains("invariant")
}
#[inline]
pub fn has_hazard(&self) -> bool {
self.ledger_flags.contains("hazard")
}
#[inline]
pub fn has_ledger(&self) -> bool {
!self.ledger_text.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct SymbolMeta {
pub symbol_id: String,
pub file: String,
pub kind: String,
}
#[derive(Debug, Clone, Default)]
pub struct ResolvedSymbol {
pub symbol_id: String,
pub qname: String,
pub file: String,
pub kind: String,
pub doc: Option<String>,
pub signature: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct FtsFilters {
pub kind: Option<String>,
pub language: Option<String>,
pub include_tests: bool,
pub tests_only: bool,
pub exclude_terms: Vec<String>,
pub paths_filter: Vec<String>,
pub exclude_paths: Vec<String>,
pub exclude_languages: Vec<String>,
}
fn tests_clause(include_tests: bool, tests_only: bool) -> &'static str {
if tests_only {
"AND tier = '2'"
} else if include_tests {
""
} else {
"AND tier != '2'"
}
}
pub struct SearchFtsDb {
conn: Connection,
}
impl SearchFtsDb {
pub fn open(db_path: &Path) -> rusqlite::Result<Self> {
let conn = Connection::open(db_path)?;
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA temp_store=MEMORY;
-- Wait out short-lived writers (e.g. asd-serve holding a sync
-- transaction) instead of failing instantly with SQLITE_BUSY.
-- A failed cache sync used to leave the DB cold forever; the
-- timeout plus the open-time self-heal makes that unreachable.
PRAGMA busy_timeout=5000;",
)?;
let db_bytes = std::fs::metadata(db_path)
.map(|m| m.len() as usize)
.unwrap_or(0);
let mut cache_kb: usize = ((db_bytes * 8 / 10) / 1024).clamp(8_192, 65_536);
let mut mmap_bytes: u64 = 268_435_456;
if let Some(project_dir) = db_path.parent() {
let cfg_path = project_dir.join(".asd").join("config.toml");
if let Ok(raw) = std::fs::read_to_string(&cfg_path) {
if let Ok(table) = raw.parse::<toml::Table>() {
if let Some(perf) = table.get("performance").and_then(|v| v.as_table()) {
if let Some(v) = perf.get("cache_size_kb").and_then(|v| v.as_integer()) {
cache_kb = (v as usize).clamp(1_024, 131_072);
}
if let Some(v) = perf.get("mmap_size_mb").and_then(|v| v.as_integer()) {
mmap_bytes = (v as u64).clamp(64, 4096) * 1024 * 1024;
}
}
}
}
}
conn.execute_batch(&format!(
"PRAGMA mmap_size={mmap_bytes}; PRAGMA cache_size=-{cache_kb};"
))?;
let db = Self { conn };
db.ensure_schema()?;
Ok(db)
}
fn ensure_schema(&self) -> rusqlite::Result<()> {
const SCHEMA_VER: i64 = 5;
let current: i64 = self
.conn
.query_row("SELECT version FROM asd_fts_meta LIMIT 1", [], |r| r.get(0))
.unwrap_or(0);
if current != SCHEMA_VER {
self.conn.execute_batch(
"DROP TABLE IF EXISTS asd_search_fts;
DROP TABLE IF EXISTS asd_search_meta;
DROP TABLE IF EXISTS asd_fts_meta;
DROP TABLE IF EXISTS asd_symbols_meta;",
)?;
}
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS asd_index_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS asd_ledger_cache (
entry_id TEXT NOT NULL,
symbol_id TEXT NOT NULL,
ref_name TEXT NOT NULL,
body TEXT NOT NULL,
PRIMARY KEY (entry_id, ref_name)
);
CREATE INDEX IF NOT EXISTS idx_asd_lc_sym
ON asd_ledger_cache(symbol_id, ref_name);",
)?;
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS asd_effects_cache (
symbol_id TEXT NOT NULL,
ref_name TEXT NOT NULL,
body TEXT NOT NULL,
PRIMARY KEY (symbol_id, ref_name)
);",
)?;
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS asd_feedback (
entry_id TEXT PRIMARY KEY,
symbol_id TEXT NOT NULL,
symbol_qname TEXT NOT NULL,
query TEXT NOT NULL,
verdict TEXT NOT NULL,
author TEXT NOT NULL,
created_at TEXT NOT NULL,
note TEXT,
file_scope TEXT,
expires_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_asd_fb_symbol ON asd_feedback(symbol_id);
CREATE INDEX IF NOT EXISTS idx_asd_fb_qname ON asd_feedback(symbol_qname);
CREATE INDEX IF NOT EXISTS idx_asd_fb_verdict ON asd_feedback(verdict);",
)?;
let _ = self
.conn
.execute("ALTER TABLE asd_feedback ADD COLUMN expires_at TEXT", []);
for col in ["withdrawn_at", "withdrawn_by", "withdrawn_reason"] {
let _ = self.conn.execute(
&format!("ALTER TABLE asd_feedback ADD COLUMN {col} TEXT"),
[],
);
}
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS asd_symbols_cache (
symbol_id TEXT NOT NULL,
ref_name TEXT NOT NULL,
qname TEXT NOT NULL,
file TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT '',
symbol_json TEXT NOT NULL,
PRIMARY KEY (symbol_id, ref_name)
);
CREATE INDEX IF NOT EXISTS idx_asd_sc_qname
ON asd_symbols_cache(qname, ref_name);
-- asd_call_edges: directed caller/callee edges.
-- direction is 'caller' (this symbol is called by neighbor)
-- or 'callee' (this symbol calls neighbor).
CREATE TABLE IF NOT EXISTS asd_call_edges (
symbol_id TEXT NOT NULL,
neighbor_id TEXT NOT NULL,
direction TEXT NOT NULL,
ref_name TEXT NOT NULL,
PRIMARY KEY (symbol_id, neighbor_id, direction, ref_name)
);
CREATE INDEX IF NOT EXISTS idx_asd_ce_lookup
ON asd_call_edges(symbol_id, direction, ref_name);",
)?;
self.conn.execute_batch(&format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS asd_search_fts USING fts5(
symbol_id UNINDEXED,
qname,
signature,
doc,
file,
language UNINDEXED,
kind UNINDEXED,
line UNINDEXED,
qname_orig UNINDEXED,
sig_orig UNINDEXED,
file_orig UNINDEXED,
tier UNINDEXED,
ledger_text UNINDEXED,
ledger_flags UNINDEXED,
tokenize = 'unicode61 remove_diacritics 1'
);
CREATE TABLE IF NOT EXISTS asd_search_meta (
file TEXT PRIMARY KEY,
indexed_at INTEGER NOT NULL
);
-- Lightweight qname→(symbol_id, file, kind) lookup table.
-- Populated alongside FTS rebuild; allows feedback functions to resolve
-- qname without traversing the git object store.
CREATE TABLE IF NOT EXISTS asd_symbols_meta (
qname TEXT PRIMARY KEY,
symbol_id TEXT NOT NULL,
file TEXT NOT NULL,
kind TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asd_fts_meta (version INTEGER PRIMARY KEY);
INSERT OR IGNORE INTO asd_fts_meta VALUES ({SCHEMA_VER});"
))
}
pub fn rebuild(&self, symbols: &[Symbol]) -> rusqlite::Result<()> {
self.rebuild_refs(&symbols.iter().collect::<Vec<_>>(), &HashMap::new())
}
pub fn rebuild_refs(
&self,
symbols: &[&Symbol],
ledger_data: &HashMap<String, (String, String)>,
) -> rusqlite::Result<()> {
self.conn.execute_batch(
"DELETE FROM asd_search_fts; DELETE FROM asd_search_meta; DELETE FROM asd_symbols_meta;",
)?;
for sym in symbols {
let (lt, lf) = ledger_data
.get(&sym.symbol_id)
.map(|(t, f)| (t.as_str(), f.as_str()))
.unwrap_or(("", ""));
self.insert_symbol(sym, lt, lf)?;
}
for sym in symbols {
let kind = format!("{:?}", sym.kind).to_lowercase();
self.conn.execute(
"INSERT OR REPLACE INTO asd_symbols_meta(qname, symbol_id, file, kind)
VALUES(?1, ?2, ?3, ?4)",
params![sym.qname, sym.symbol_id, sym.file, kind],
)?;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
self.conn.execute(
"INSERT OR REPLACE INTO asd_index_meta (key, value) VALUES ('indexed_at', ?1)",
params![now.to_string()],
)?;
Ok(())
}
pub fn last_indexed_at(&self) -> Option<i64> {
self.conn
.query_row(
"SELECT value FROM asd_index_meta WHERE key = 'indexed_at' LIMIT 1",
[],
|r| r.get::<_, String>(0),
)
.ok()
.and_then(|s| s.parse().ok())
}
pub fn fts_last_rebuild_ok(&self) -> Option<bool> {
self.conn
.query_row(
"SELECT value FROM asd_index_meta WHERE key = 'fts_last_rebuild_ok' LIMIT 1",
[],
|r| r.get::<_, String>(0),
)
.ok()
.map(|s| s == "1")
}
pub fn mark_symbols_indexed(&self, fts_succeeded: bool) -> rusqlite::Result<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
self.conn.execute(
"INSERT OR REPLACE INTO asd_index_meta (key, value) VALUES ('symbols_indexed_at', ?1)",
params![now.to_string()],
)?;
self.conn.execute(
"INSERT OR REPLACE INTO asd_index_meta (key, value) \
VALUES ('fts_last_rebuild_ok', ?1)",
params![if fts_succeeded { "1" } else { "0" }],
)?;
Ok(())
}
pub fn fts_symbol_row_count(&self) -> i64 {
self.conn
.query_row("SELECT COUNT(*) FROM asd_search_fts", [], |r| r.get(0))
.unwrap_or(0)
}
pub fn symbols_cached_for(&self, ref_name: &str) -> bool {
self.conn
.query_row(
"SELECT COUNT(*) FROM asd_symbols_cache WHERE ref_name = ?1 LIMIT 1",
params![ref_name],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false)
}
pub fn sync_symbols(
&self,
symbols: &[&crate::schema::Symbol],
ref_name: &str,
) -> rusqlite::Result<()> {
let tx = self.conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM asd_symbols_cache WHERE ref_name = ?1",
params![ref_name],
)?;
{
let mut stmt = tx.prepare(
"INSERT OR REPLACE INTO asd_symbols_cache
(symbol_id, ref_name, qname, file, kind, symbol_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)?;
for sym in symbols {
let json = serde_json::to_string(sym).unwrap_or_default();
let kind = format!("{:?}", sym.kind).to_lowercase();
stmt.execute(params![
sym.symbol_id,
ref_name,
sym.qname,
sym.file,
kind,
json
])?;
}
}
tx.commit()
}
pub fn sync_call_edges(
&self,
callees_of: &HashMap<String, Vec<String>>,
callers_of: &HashMap<String, Vec<String>>,
ref_name: &str,
) -> rusqlite::Result<()> {
let tx = self.conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM asd_call_edges WHERE ref_name = ?1",
params![ref_name],
)?;
{
let mut stmt = tx.prepare(
"INSERT OR REPLACE INTO asd_call_edges
(symbol_id, neighbor_id, direction, ref_name)
VALUES (?1, ?2, ?3, ?4)",
)?;
for (caller_id, callee_ids) in callees_of {
for callee_id in callee_ids {
stmt.execute(params![caller_id, callee_id, "callee", ref_name])?;
}
}
for (callee_id, caller_ids) in callers_of {
for caller_id in caller_ids {
stmt.execute(params![callee_id, caller_id, "caller", ref_name])?;
}
}
}
tx.commit()
}
pub fn build_id_map_cached(&self, ref_name: &str) -> HashMap<String, crate::schema::Symbol> {
let mut stmt = match self
.conn
.prepare("SELECT symbol_id, symbol_json FROM asd_symbols_cache WHERE ref_name = ?1")
{
Ok(s) => s,
Err(_) => return HashMap::new(),
};
let rows = match stmt.query_map(params![ref_name], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
}) {
Ok(r) => r,
Err(_) => return HashMap::new(),
};
let mut map = HashMap::new();
for row in rows.flatten() {
let (id, json) = row;
if let Ok(sym) = serde_json::from_str::<crate::schema::Symbol>(&json) {
map.insert(id, sym);
}
}
map
}
pub fn files_for_qnames(
&self,
qnames: &[&str],
_ref_name: &str,
) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::with_capacity(qnames.len());
if qnames.is_empty() {
return out;
}
let placeholders: Vec<&str> = (0..qnames.len()).map(|_| "?").collect();
let sql = format!(
"SELECT qname, file FROM asd_symbols_meta WHERE qname IN ({})",
placeholders.join(", ")
);
let mut stmt = match self.conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return out,
};
let rows = stmt.query_map(
rusqlite::params_from_iter(qnames.iter().map(|q| q as &dyn rusqlite::ToSql)),
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
);
if let Ok(rows) = rows {
for r in rows.flatten() {
out.insert(r.0, r.1);
}
}
out
}
pub fn get_symbol_by_qname_cached(
&self,
qname: &str,
ref_name: &str,
) -> Option<crate::schema::Symbol> {
self.conn
.query_row(
"SELECT symbol_json FROM asd_symbols_cache
WHERE qname = ?1 AND ref_name = ?2 LIMIT 1",
params![qname, ref_name],
|r| r.get::<_, String>(0),
)
.ok()
.and_then(|json| serde_json::from_str(&json).ok())
}
pub fn get_neighbors_cached(
&self,
symbol_id: &str,
direction: &str,
ref_name: &str,
) -> Vec<String> {
let mut stmt = match self.conn.prepare(
"SELECT neighbor_id FROM asd_call_edges
WHERE symbol_id = ?1 AND direction = ?2 AND ref_name = ?3",
) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
stmt.query_map(params![symbol_id, direction, ref_name], |r| {
r.get::<_, String>(0)
})
.map(|rows| rows.flatten().collect())
.unwrap_or_default()
}
pub fn build_edge_maps_cached(
&self,
ref_name: &str,
) -> (HashMap<String, Vec<String>>, HashMap<String, Vec<String>>) {
let mut callers_of: HashMap<String, Vec<String>> = HashMap::new();
let mut callees_of: HashMap<String, Vec<String>> = HashMap::new();
let mut stmt = match self.conn.prepare(
"SELECT symbol_id, neighbor_id, direction FROM asd_call_edges
WHERE ref_name = ?1",
) {
Ok(s) => s,
Err(_) => return (callers_of, callees_of),
};
let rows = match stmt.query_map(params![ref_name], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
}) {
Ok(r) => r,
Err(_) => return (callers_of, callees_of),
};
for (symbol_id, neighbor_id, direction) in rows.flatten() {
match direction.as_str() {
"caller" => callers_of.entry(symbol_id).or_default().push(neighbor_id),
"callee" => callees_of.entry(symbol_id).or_default().push(neighbor_id),
_ => {}
}
}
(callers_of, callees_of)
}
pub fn symbol_count(&self) -> usize {
self.conn
.query_row("SELECT COUNT(*) FROM asd_search_fts", [], |r| {
r.get::<_, i64>(0)
})
.map(|n| n as usize)
.unwrap_or(0)
}
pub fn annotated_symbol_count(&self, ref_name: &str) -> usize {
self.conn
.query_row(
"SELECT COUNT(DISTINCT symbol_id) FROM asd_ledger_cache WHERE ref_name = ?1",
params![ref_name],
|r| r.get::<_, i64>(0),
)
.map(|n| n as usize)
.unwrap_or(0)
}
fn insert_symbol(
&self,
sym: &Symbol,
ledger_text: &str,
ledger_flags: &str,
) -> rusqlite::Result<()> {
let qname_exp = expand_identifier(&sym.qname);
let sig_orig = sym.signature.as_deref().unwrap_or("");
let sig_exp = if sig_orig.is_empty() {
String::new()
} else {
expand_text(sig_orig)
};
let doc = sym.doc.as_deref().unwrap_or("");
let file_exp = expand_text(&sym.file);
let kind = format!("{:?}", sym.kind).to_lowercase();
let tier = symbol_tier(&sym.file).to_string();
self.conn.execute(
"INSERT INTO asd_search_fts(
symbol_id, qname, signature, doc, file, language, kind, line,
qname_orig, sig_orig, file_orig, tier, ledger_text, ledger_flags)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
params![
sym.symbol_id,
qname_exp,
sig_exp,
doc,
file_exp,
sym.language,
kind,
sym.start.line,
sym.qname,
sym.signature.as_deref().unwrap_or(""),
sym.file,
tier,
ledger_text,
ledger_flags,
],
)?;
Ok(())
}
pub fn search(
&self,
query: &str,
filters: &FtsFilters,
limit: usize,
) -> rusqlite::Result<Vec<FtsHit>> {
if query.trim().is_empty() {
return Ok(vec![]);
}
let tokens: Vec<String> = query
.split(|c: char| c.is_whitespace() || c == '_' || c == '-' || c == '.')
.map(|t| t.to_lowercase())
.filter(|t| t.len() >= 2 && !is_stopword(t))
.collect();
if tokens.is_empty() {
return Ok(vec![]);
}
let match_expr = tokens
.iter()
.map(|t| format!("\"{}\"", t.replace('"', "")))
.collect::<Vec<_>>()
.join(" OR ");
let kind_clause = filters
.kind
.as_deref()
.map(|k| format!("AND kind = '{}'", k.to_lowercase().replace('\'', "")))
.unwrap_or_default();
let lang_clause = filters
.language
.as_deref()
.map(|l| format!("AND language = '{}'", l.to_lowercase().replace('\'', "")))
.unwrap_or_default();
let test_clause = tests_clause(filters.include_tests, filters.tests_only);
let fetch = (limit * 4).max(80);
let sql = format!(
"SELECT symbol_id, language, kind, line, doc,
qname_orig, sig_orig, file_orig, tier,
ledger_text, ledger_flags,
bm25(asd_search_fts, 10.0, 5.0, 5.0, 2.0) AS score
FROM asd_search_fts
WHERE asd_search_fts MATCH ?1
{test_clause}
{kind_clause}
{lang_clause}
ORDER BY score
LIMIT {fetch}"
);
let mut stmt = self.conn.prepare(&sql)?;
let hits = stmt
.query_map(params![match_expr], |row| {
let bm25_raw: f64 = row.get(11)?;
let sig_orig: Option<String> = row.get(6)?;
let tier_str: String = row.get(8).unwrap_or_default();
let tier: SymbolTier = tier_str.parse().unwrap_or(0);
Ok(FtsHit {
bm25_score: -bm25_raw,
symbol_id: row.get(0)?,
language: row.get(1)?,
kind: row.get(2)?,
line: row.get::<_, u32>(3).unwrap_or(0),
doc: row.get(4)?,
qname: row.get(5)?,
signature: sig_orig.filter(|s| !s.is_empty()),
file: row.get(7)?,
tier,
ledger_text: row.get::<_, String>(9).unwrap_or_default(),
ledger_flags: row.get::<_, String>(10).unwrap_or_default(),
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(hits)
}
pub fn count_distinct_files_per_token(
&self,
tokens: &[&str],
include_tests: bool,
) -> rusqlite::Result<Vec<(String, usize)>> {
if tokens.is_empty() {
return Ok(vec![]);
}
let test_clause = if include_tests { "" } else { "AND tier != '2'" };
let mut parts = Vec::with_capacity(tokens.len());
let mut params_vec: Vec<String> = Vec::with_capacity(tokens.len());
for (i, tok) in tokens.iter().enumerate() {
let tok_clean = tok.replace('"', "");
parts.push(format!(
"SELECT ?{} AS tok, COUNT(DISTINCT file_orig) AS cnt \
FROM asd_search_fts \
WHERE asd_search_fts MATCH '\"{}\"' {test_clause}",
i + 1,
tok_clean
));
params_vec.push(tok_clean);
}
let sql = parts.join(" UNION ALL ");
let mut stmt = self.conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec
.iter()
.map(|s| s as &dyn rusqlite::types::ToSql)
.collect();
let results = stmt
.query_map(params_refs.as_slice(), |row| {
let tok: String = row.get(0)?;
let cnt: i64 = row.get(1)?;
Ok((tok, cnt as usize))
})?
.filter_map(|r| r.ok())
.collect();
Ok(results)
}
pub fn has_exact_symbol_name(&self, name: &str) -> bool {
if name.len() < 3 {
return false;
}
let name_lc = name.to_lowercase();
let sql = "SELECT 1 FROM asd_search_fts \
WHERE lower(qname_orig) = ?1 \
OR lower(qname_orig) LIKE ?2 \
LIMIT 1";
let suffix_pattern = format!("%.{}", name_lc);
self.conn
.query_row(sql, rusqlite::params![name_lc, suffix_pattern], |_| {
Ok(true)
})
.unwrap_or(false)
}
pub fn has_data(&self) -> bool {
self.conn
.query_row("SELECT COUNT(*) FROM asd_search_fts LIMIT 1", [], |r| {
r.get::<_, i64>(0)
})
.map(|n| n > 0)
.unwrap_or(false)
}
pub fn get_symbols_meta_bulk(&self, qnames: &[&str]) -> HashMap<String, SymbolMeta> {
if qnames.is_empty() {
return HashMap::new();
}
let placeholders: Vec<String> = (1..=qnames.len()).map(|i| format!("?{i}")).collect();
let sql = format!(
"SELECT qname, symbol_id, file, kind FROM asd_symbols_meta WHERE qname IN ({})",
placeholders.join(", ")
);
let mut stmt = match self.conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return HashMap::new(),
};
let params_refs: Vec<&dyn rusqlite::types::ToSql> = qnames
.iter()
.map(|q| q as &dyn rusqlite::types::ToSql)
.collect();
stmt.query_map(params_refs.as_slice(), |row| {
Ok((
row.get::<_, String>(0)?,
SymbolMeta {
symbol_id: row.get(1)?,
file: row.get(2)?,
kind: row.get(3)?,
},
))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
pub fn anchor_candidates(
&self,
tokens: &[String],
existing_qnames: &std::collections::HashSet<String>,
limit: usize,
) -> Vec<(String, String)> {
if tokens.is_empty() {
return vec![];
}
let flag_clause = "(ledger_flags LIKE '%invariant%' OR ledger_flags LIKE '%hazard%')";
let text_clauses: Vec<String> = tokens
.iter()
.map(|t| format!("ledger_text LIKE '%{}%'", t.replace('\'', "''")))
.collect();
let text_clause = format!("({})", text_clauses.join(" OR "));
let sql = format!(
"SELECT qname_orig, symbol_id
FROM asd_search_fts
WHERE ledger_text != ''
AND {flag_clause}
AND {text_clause}
AND tier != '2'
LIMIT {limit}"
);
let mut stmt = match self.conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| {
rows.filter_map(|r| r.ok())
.filter(|(qname, _)| !existing_qnames.contains(qname))
.collect()
})
.unwrap_or_default()
}
pub fn resolve_qnames_bulk(&self, qnames: &[&str]) -> HashMap<String, ResolvedSymbol> {
if qnames.is_empty() {
return HashMap::new();
}
let placeholders: Vec<String> = (1..=qnames.len()).map(|i| format!("?{i}")).collect();
let sql = format!(
"SELECT qname_orig, symbol_id, file_orig, kind, doc, sig_orig
FROM asd_search_fts
WHERE qname_orig IN ({})",
placeholders.join(", ")
);
let mut stmt = match self.conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return HashMap::new(),
};
let params_refs: Vec<&dyn rusqlite::types::ToSql> = qnames
.iter()
.map(|q| q as &dyn rusqlite::types::ToSql)
.collect();
stmt.query_map(params_refs.as_slice(), |row| {
let qname: String = row.get(0)?;
let doc_raw: Option<String> = row.get(4)?;
let sig_raw: Option<String> = row.get(5)?;
Ok((
qname.clone(),
ResolvedSymbol {
symbol_id: row.get(1)?,
qname,
file: row.get(2)?,
kind: row.get(3)?,
doc: doc_raw.filter(|s| !s.is_empty()),
signature: sig_raw.filter(|s| !s.is_empty()),
},
))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
pub fn resolve_symbol_ids_bulk(&self, symbol_ids: &[&str]) -> HashMap<String, ResolvedSymbol> {
if symbol_ids.is_empty() {
return HashMap::new();
}
let placeholders: Vec<String> = (1..=symbol_ids.len()).map(|i| format!("?{i}")).collect();
let sql = format!(
"SELECT symbol_id, qname_orig, file_orig, kind, doc, sig_orig
FROM asd_search_fts
WHERE symbol_id IN ({})",
placeholders.join(", ")
);
let mut stmt = match self.conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return HashMap::new(),
};
let params_refs: Vec<&dyn rusqlite::types::ToSql> = symbol_ids
.iter()
.map(|s| s as &dyn rusqlite::types::ToSql)
.collect();
stmt.query_map(params_refs.as_slice(), |row| {
let sym_id: String = row.get(0)?;
let doc_raw: Option<String> = row.get(4)?;
let sig_raw: Option<String> = row.get(5)?;
Ok((
sym_id.clone(),
ResolvedSymbol {
symbol_id: sym_id,
qname: row.get(1)?,
file: row.get(2)?,
kind: row.get(3)?,
doc: doc_raw.filter(|s| !s.is_empty()),
signature: sig_raw.filter(|s| !s.is_empty()),
},
))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
pub fn symbols_with_constraint_penalties(
&self,
ref_name: &str,
) -> rusqlite::Result<Vec<String>> {
let pairs = self.symbols_with_constraint_penalties_scoped(ref_name)?;
Ok(pairs.into_iter().map(|(sid, _)| sid).collect())
}
pub fn symbols_with_constraint_penalties_scoped(
&self,
ref_name: &str,
) -> rusqlite::Result<Vec<(String, Option<Vec<String>>)>> {
let mut stmt = self.conn.prepare(
"SELECT symbol_id, body FROM asd_ledger_cache
WHERE ref_name = ?1
AND json_extract(body, '$.kind') IN ('decision', 'constraint')
AND json_extract(body, '$.role') IN ('stale-api', 'audit-pending')",
)?;
let rows = stmt
.query_map(params![ref_name], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})?
.filter_map(|r| r.ok())
.map(|(sid, body)| {
let scope = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("body").and_then(|b| {
b.as_str()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.or_else(|| Some(b.clone()))
})
})
.and_then(|inner| inner.get("scope").and_then(|s| s.as_array().cloned()))
.map(|arr| {
arr.into_iter()
.filter_map(|g| g.as_str().map(String::from))
.filter(|g| !g.is_empty())
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty());
(sid, scope)
})
.collect::<Vec<_>>();
Ok(rows)
}
pub fn ledger_entry_count_for(&self, symbol_id: &str, ref_name: &str) -> usize {
self.conn
.query_row(
"SELECT COUNT(*) FROM asd_ledger_cache WHERE symbol_id = ?1 AND ref_name = ?2",
params![symbol_id, ref_name],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0) as usize
}
pub fn upsert_ledger_entry(&self, entry: &LedgerEntry, ref_name: &str) -> rusqlite::Result<()> {
let body = serde_json::to_string(entry).unwrap_or_else(|_| "{}".to_string());
self.conn.execute(
"INSERT OR REPLACE INTO asd_ledger_cache (entry_id, symbol_id, ref_name, body)
VALUES (?1, ?2, ?3, ?4)",
params![entry.entry_id, entry.symbol_id, ref_name, body],
)?;
let summary_lower = entry.summary.to_lowercase();
let kind_str = entry.kind.as_str();
let _ = self.conn.execute(
"UPDATE asd_search_fts
SET ledger_text = CASE
WHEN ledger_text IS NULL OR ledger_text = '' THEN ?1
ELSE ledger_text || ' ' || ?1
END,
ledger_flags = CASE
WHEN ledger_flags IS NULL OR ledger_flags = '' THEN ?2
WHEN INSTR(',' || ledger_flags || ',', ',' || ?2 || ',') > 0 THEN ledger_flags
ELSE ledger_flags || ',' || ?2
END
WHERE symbol_id = ?3",
params![summary_lower, kind_str, entry.symbol_id],
);
Ok(())
}
pub fn list_ledger_entries_for(
&self,
symbol_id: &str,
ref_name: &str,
) -> rusqlite::Result<Vec<LedgerEntry>> {
let mut stmt = self.conn.prepare(
"SELECT body FROM asd_ledger_cache
WHERE symbol_id = ?1 AND ref_name = ?2
ORDER BY rowid DESC",
)?;
let entries = stmt
.query_map(params![symbol_id, ref_name], |row| {
let body: String = row.get(0)?;
Ok(body)
})?
.filter_map(|r| r.ok())
.filter_map(|body| serde_json::from_str::<LedgerEntry>(&body).ok())
.collect();
Ok(entries)
}
pub fn ledger_cache_entry_ids(&self, ref_name: &str) -> rusqlite::Result<Vec<String>> {
let mut stmt = self
.conn
.prepare("SELECT entry_id FROM asd_ledger_cache WHERE ref_name = ?1")?;
let ids = stmt
.query_map(params![ref_name], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(ids)
}
pub fn all_ledger_entries(
&self,
ref_name: &str,
) -> rusqlite::Result<(Vec<LedgerEntry>, Vec<String>)> {
let mut stmt = self
.conn
.prepare("SELECT entry_id, body FROM asd_ledger_cache WHERE ref_name = ?1")?;
let rows = stmt.query_map(params![ref_name], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut entries = Vec::new();
let mut unparseable = Vec::new();
for row in rows {
let (entry_id, body) = row?;
match serde_json::from_str::<LedgerEntry>(&body) {
Ok(e) => entries.push(e),
Err(_) => unparseable.push(entry_id),
}
}
Ok((entries, unparseable))
}
pub fn sync_ledger_entries(
&self,
entries: &[(String, LedgerEntry)],
ref_name: &str,
) -> rusqlite::Result<()> {
self.conn.execute_batch("BEGIN;")?;
for (symbol_id, entry) in entries {
let body = serde_json::to_string(entry).unwrap_or_else(|_| "{}".to_string());
if let Err(err) = self.conn.execute(
"INSERT OR REPLACE INTO asd_ledger_cache (entry_id, symbol_id, ref_name, body)
VALUES (?1, ?2, ?3, ?4)",
params![entry.entry_id, symbol_id, ref_name, body],
) {
eprintln!("asd: ledger cache sync warning — {err}");
}
}
self.conn.execute_batch("COMMIT;")?;
Ok(())
}
pub fn effects_cached_for(&self, symbol_id: &str, ref_name: &str) -> bool {
self.conn
.query_row(
"SELECT 1 FROM asd_effects_cache WHERE symbol_id = ?1 AND ref_name = ?2 LIMIT 1",
params![symbol_id, ref_name],
|_| Ok(()),
)
.is_ok()
}
pub fn upsert_effects(
&self,
symbol_id: &str,
ref_name: &str,
decl: &EffectDecl,
) -> rusqlite::Result<()> {
let body = serde_json::to_string(decl).unwrap_or_else(|_| "{}".to_string());
self.conn.execute(
"INSERT OR REPLACE INTO asd_effects_cache (symbol_id, ref_name, body)
VALUES (?1, ?2, ?3)",
params![symbol_id, ref_name, body],
)?;
Ok(())
}
pub fn delete_effects_for(
&self,
symbol_ids: &[String],
ref_name: &str,
) -> rusqlite::Result<()> {
if symbol_ids.is_empty() {
return Ok(());
}
self.conn.execute_batch("BEGIN;")?;
for symbol_id in symbol_ids {
if let Err(e) = self.conn.execute(
"DELETE FROM asd_effects_cache WHERE symbol_id = ?1 AND ref_name = ?2",
params![symbol_id, ref_name],
) {
let _ = self.conn.execute_batch("ROLLBACK;");
return Err(e);
}
}
self.conn.execute_batch("COMMIT;")
}
pub fn get_effects_for(
&self,
symbol_id: &str,
ref_name: &str,
) -> rusqlite::Result<Option<EffectDecl>> {
match self.conn.query_row(
"SELECT body FROM asd_effects_cache WHERE symbol_id = ?1 AND ref_name = ?2 LIMIT 1",
params![symbol_id, ref_name],
|row| row.get::<_, String>(0),
) {
Ok(body) => Ok(serde_json::from_str::<EffectDecl>(&body).ok()),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
pub fn sync_effects(
&self,
entries: &[(String, EffectDecl)],
ref_name: &str,
) -> rusqlite::Result<()> {
self.conn.execute_batch("BEGIN;")?;
for (symbol_id, decl) in entries {
let body = serde_json::to_string(decl).unwrap_or_else(|_| "{}".to_string());
if let Err(err) = self.conn.execute(
"INSERT OR REPLACE INTO asd_effects_cache (symbol_id, ref_name, body)
VALUES (?1, ?2, ?3)",
params![symbol_id, ref_name, body],
) {
eprintln!("asd: effects cache sync warning — {err}");
}
}
self.conn.execute_batch("COMMIT;")?;
Ok(())
}
pub fn feedback_count(&self) -> usize {
self.conn
.query_row("SELECT COUNT(*) FROM asd_feedback", [], |r| {
r.get::<_, i64>(0)
})
.unwrap_or(0) as usize
}
pub fn upsert_feedback(&self, e: &FeedbackEntry) -> rusqlite::Result<()> {
self.conn.execute(
"INSERT OR REPLACE INTO asd_feedback
(entry_id, symbol_id, symbol_qname, query, verdict, author, created_at, note, file_scope, expires_at,
withdrawn_at, withdrawn_by, withdrawn_reason)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
rusqlite::params![
e.entry_id,
e.symbol_id,
e.symbol_qname,
e.query,
e.verdict.as_str(),
e.author,
e.created_at.to_rfc3339(),
e.note,
e.file_scope,
e.expires_at.map(|t| t.to_rfc3339()),
e.withdrawn_at.map(|t| t.to_rfc3339()),
e.withdrawn_by,
e.withdrawn_reason,
],
)?;
Ok(())
}
pub fn delete_feedback(&self, entry_id: &str) -> rusqlite::Result<usize> {
self.conn.execute(
"DELETE FROM asd_feedback WHERE entry_id = ?1",
rusqlite::params![entry_id],
)
}
pub fn list_all_feedback(&self) -> rusqlite::Result<Vec<FeedbackEntry>> {
let mut stmt = self.conn.prepare(
"SELECT entry_id, symbol_id, symbol_qname, query, verdict, author, created_at, note, file_scope, expires_at,
withdrawn_at, withdrawn_by, withdrawn_reason
FROM asd_feedback
ORDER BY created_at DESC",
)?;
let entries = stmt
.query_map([], |row| {
let verdict_str: String = row.get(4)?;
let verdict =
FeedbackVerdict::from_str(&verdict_str).unwrap_or(FeedbackVerdict::Useful);
let ts_str: String = row.get(6)?;
let created_at: DateTime<Utc> = DateTime::parse_from_rfc3339(&ts_str)
.map(|d| d.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let expires_at: Option<DateTime<Utc>> = row
.get::<_, Option<String>>(9)?
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|d| d.with_timezone(&Utc));
let withdrawn_at: Option<DateTime<Utc>> = row
.get::<_, Option<String>>(10)?
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|d| d.with_timezone(&Utc));
Ok(FeedbackEntry {
entry_id: row.get(0)?,
symbol_id: row.get(1)?,
symbol_qname: row.get(2)?,
query: row.get(3)?,
verdict,
author: row.get(5)?,
created_at,
note: row.get(7)?,
file_scope: row.get(8)?,
expires_at,
withdrawn_at,
withdrawn_by: row.get(11)?,
withdrawn_reason: row.get(12)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
pub fn sync_feedback_entries(&self, entries: &[FeedbackEntry]) -> rusqlite::Result<()> {
self.conn.execute_batch("BEGIN;")?;
for e in entries {
if let Err(err) = self.upsert_feedback(e) {
eprintln!("asd: feedback sync warning — {err}");
}
}
self.conn.execute_batch("COMMIT;")?;
Ok(())
}
pub fn file_stem_candidates(
&self,
token: &str,
filters: &FtsFilters,
limit: usize,
) -> rusqlite::Result<Vec<FtsHit>> {
if token.len() < 2 {
return Ok(vec![]);
}
let test_clause = tests_clause(filters.include_tests, filters.tests_only);
let kind_clause = filters
.kind
.as_deref()
.map(|k| format!("AND kind = '{}'", k.to_lowercase().replace('\'', "")))
.unwrap_or_default();
let sql = format!(
"SELECT symbol_id, language, kind, MIN(line) as line, doc,
qname_orig, sig_orig, file_orig, tier,
ledger_text, ledger_flags
FROM asd_search_fts
WHERE lower(file_orig) LIKE '%' || lower(?1) || '%'
{test_clause}
{kind_clause}
GROUP BY file_orig
LIMIT {limit}"
);
let token_owned = token.to_lowercase();
let mut stmt = self.conn.prepare(&sql)?;
let hits = stmt
.query_map(params![token_owned], |row| {
let sig_orig: Option<String> = row.get(6)?;
let tier_str: String = row.get(8).unwrap_or_default();
let tier: SymbolTier = tier_str.parse().unwrap_or(0);
Ok(FtsHit {
bm25_score: 0.0,
symbol_id: row.get(0)?,
language: row.get(1)?,
kind: row.get(2)?,
line: row.get::<_, u32>(3).unwrap_or(0),
doc: row.get(4)?,
qname: row.get(5)?,
signature: sig_orig.filter(|s| !s.is_empty()),
file: row.get(7)?,
tier,
ledger_text: row.get::<_, String>(9).unwrap_or_default(),
ledger_flags: row.get::<_, String>(10).unwrap_or_default(),
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(hits)
}
pub fn file_stem_candidates_batch(
&self,
tokens: &[String],
filters: &FtsFilters,
limit: usize,
) -> rusqlite::Result<Vec<FtsHit>> {
let valid: Vec<String> = tokens
.iter()
.filter(|t| t.len() >= 2)
.map(|t| t.to_lowercase())
.collect();
if valid.is_empty() {
return Ok(vec![]);
}
let test_clause = tests_clause(filters.include_tests, filters.tests_only);
let kind_clause = filters
.kind
.as_deref()
.map(|k| format!("AND kind = '{}'", k.to_lowercase().replace('\'', "")))
.unwrap_or_default();
let arm_sql = |n: usize| -> String {
format!(
"SELECT symbol_id, language, kind, MIN(line) AS line, doc,
qname_orig, sig_orig, file_orig, tier,
ledger_text, ledger_flags
FROM asd_search_fts
WHERE lower(file_orig) LIKE '%' || ?{n} || '%'
{test_clause}
{kind_clause}
GROUP BY file_orig"
)
};
let sql = valid
.iter()
.enumerate()
.map(|(i, _)| arm_sql(i + 1))
.collect::<Vec<_>>()
.join("\nUNION ALL\n");
let mut stmt = self.conn.prepare(&sql)?;
let mut seen_files: std::collections::HashSet<String> = std::collections::HashSet::new();
let hits: Vec<FtsHit> = stmt
.query_map(rusqlite::params_from_iter(valid.iter()), |row| {
let sig_orig: Option<String> = row.get(6)?;
let tier_str: String = row.get(8).unwrap_or_default();
let tier: SymbolTier = tier_str.parse().unwrap_or(0);
Ok(FtsHit {
bm25_score: 0.0,
symbol_id: row.get(0)?,
language: row.get(1)?,
kind: row.get(2)?,
line: row.get::<_, u32>(3).unwrap_or(0),
doc: row.get(4)?,
qname: row.get(5)?,
signature: sig_orig.filter(|s| !s.is_empty()),
file: row.get(7)?,
tier,
ledger_text: row.get::<_, String>(9).unwrap_or_default(),
ledger_flags: row.get::<_, String>(10).unwrap_or_default(),
})
})?
.filter_map(|r| r.ok())
.filter(|h| seen_files.insert(h.file.clone()))
.take(limit)
.collect();
Ok(hits)
}
}
pub fn hybrid_boost(hit: &FtsHit, tokens: &[String]) -> f64 {
if tokens.is_empty() {
return 0.0;
}
let path_words: Vec<String> = hit
.file
.split(|c: char| c == '/' || c == '\\' || c == '.')
.filter(|s| !s.is_empty())
.flat_map(|seg| {
let mut words: Vec<String> = vec![seg.to_lowercase()];
words.extend(split_camel(seg).iter().map(|w| w.to_lowercase()));
words
})
.filter(|w| w.len() >= 2)
.collect();
let path_boost = tokens
.iter()
.filter(|t| path_words.iter().any(|w| w == t.as_str()))
.count() as f64
* 2.5;
let last_seg = hit.qname.rsplit('.').next().unwrap_or(&hit.qname);
let name_words: Vec<String> = {
let mut words: Vec<String> = vec![last_seg.to_lowercase()];
words.extend(split_camel(last_seg).iter().map(|w| w.to_lowercase()));
words
};
let name_boost = tokens
.iter()
.filter(|t| name_words.iter().any(|w| w == t.as_str()))
.count() as f64
* 2.0;
let phrase_bonus = if tokens.len() >= 2 {
let mut bonus = 0.0f64;
let lower_tokens: Vec<&str> = tokens.iter().map(|t| t.as_str()).collect();
for window_start in 0..name_words.len().saturating_sub(1) {
for pair_len in 2..=(lower_tokens.len().min(name_words.len() - window_start)) {
let name_slice = &name_words[window_start..window_start + pair_len];
for tok_start in 0..=lower_tokens.len().saturating_sub(pair_len) {
let tok_slice = &lower_tokens[tok_start..tok_start + pair_len];
if name_slice
.iter()
.map(|s| s.as_str())
.eq(tok_slice.iter().copied())
{
bonus = bonus.max(pair_len as f64 * 3.0);
}
}
}
}
bonus
} else {
0.0
};
let tier_penalty = if hit.tier == 1 { -2.0 } else { 0.0 };
const GENERIC_TOKENS: &[&str] = &[
"state",
"update",
"local",
"position",
"value",
"data",
"item",
"list",
"info",
"event",
"action",
"type",
"mode",
"flag",
"current",
"node",
"result",
"status",
"record",
"entry",
"object",
"element",
"get",
"set",
"add",
"remove",
"reset",
"apply",
"build",
"make",
"playhead",
"cursor",
"progress",
"indicator",
"tick",
];
let matched_tokens: Vec<&str> = tokens
.iter()
.filter(|t| {
name_words.iter().any(|w| w == t.as_str()) || path_words.iter().any(|w| w == t.as_str())
})
.map(|t| t.as_str())
.collect();
let generic_penalty = if !matched_tokens.is_empty()
&& matched_tokens.iter().all(|t| GENERIC_TOKENS.contains(t))
{
-2.5 } else {
0.0
};
path_boost + name_boost + phrase_bonus + tier_penalty + generic_penalty
}
pub fn format_age(indexed_at: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let secs = (now - indexed_at).max(0);
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}
pub fn stale_warning(db_path: &std::path::Path, threshold_secs: u64) -> Option<String> {
let fts = SearchFtsDb::open(db_path).ok()?;
if !fts.has_data() {
return Some("asd: index is empty — run 'asd index <dir>' to build it.".to_string());
}
if fts.fts_last_rebuild_ok() == Some(false) {
return Some(
"asd: symbols are indexed but the FTS search index failed to rebuild \
during the last 'asd index' run (database may have been locked). \
Search ranking may be stale — re-run 'asd index <dir>' to repair."
.to_string(),
);
}
let indexed_at = fts.last_indexed_at()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let age_secs = (now - indexed_at).max(0) as u64;
if age_secs > threshold_secs {
Some(format!(
"asd: index may be stale — last indexed {}. Run 'asd index <dir>' to update.",
format_age(indexed_at)
))
} else {
None
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StaleWarning {
pub message: String,
pub severity: StaleSeverity,
pub age_secs: u64,
pub indexed_at: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StaleSeverity {
Critical,
Soft,
}
pub fn stale_warning_classified(
db_path: &std::path::Path,
threshold_secs: u64,
) -> Option<StaleWarning> {
let fts = SearchFtsDb::open(db_path).ok()?;
if !fts.has_data() {
return Some(StaleWarning {
message: "asd: index is empty — run 'asd index <dir>' to build it.".to_string(),
severity: StaleSeverity::Critical,
age_secs: 0,
indexed_at: None,
});
}
if fts.fts_last_rebuild_ok() == Some(false) {
return Some(StaleWarning {
message: "asd: symbols are indexed but the FTS search index failed to rebuild \
during the last 'asd index' run (database may have been locked). \
Search ranking may be stale — re-run 'asd index <dir>' to repair."
.to_string(),
severity: StaleSeverity::Critical,
age_secs: 0,
indexed_at: None,
});
}
let indexed_at = fts.last_indexed_at()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let age_secs = (now - indexed_at).max(0) as u64;
if age_secs > threshold_secs {
Some(StaleWarning {
message: format!(
"asd: index may be stale — last indexed {}. Run 'asd index <dir>' to update.",
format_age(indexed_at)
),
severity: StaleSeverity::Soft,
age_secs,
indexed_at: Some(indexed_at),
})
} else {
None
}
}
pub const SOFT_STALE_THRESHOLD_SECS: u64 = 86_400;
pub fn compute_index_consistency(asg_symbols: usize, fts_symbols: usize) -> serde_json::Value {
let delta = asg_symbols as i64 - fts_symbols as i64;
if delta == 0 {
return serde_json::Value::Null;
}
let advice = if delta > 0 {
format!(
"ASG has {delta} symbol{p} not in the FTS search cache — run 'asd index' to rebuild the search index.",
p = if delta == 1 { "" } else { "s" }
)
} else {
let extra = -delta;
format!(
"FTS holds {extra} stale symbol{p} no longer in the ASG — run 'asd index' to rebuild.",
p = if extra == 1 { "" } else { "s" }
)
};
serde_json::json!({
"asg_symbols": asg_symbols,
"fts_symbols": fts_symbols,
"delta": delta,
"consistent": false,
"advice": advice,
})
}
pub const AGENT_DEFAULT_BUDGET: usize = 8_000;
pub fn trim_for_agent(v: &serde_json::Value, max_list: usize) -> serde_json::Value {
use serde_json::Value;
const DROP_FIELDS: &[&str] = &["body", "doc", "tokens"];
const TRUNCATE_ARRAYS: &[&str] = &[
"callers",
"callees",
"notes",
"decisions_and_notes",
"proofs",
"ownership",
"other_ledger",
];
const SIMPLIFY_REFS: &[&str] = &["callers", "callees"];
match v {
Value::Object(map) => {
let mut out = serde_json::Map::new();
for (k, val) in map {
if DROP_FIELDS.contains(&k.as_str()) {
continue;
}
let trimmed = trim_for_agent(val, max_list);
let final_val = if TRUNCATE_ARRAYS.contains(&k.as_str()) {
if let Value::Array(arr) = &trimmed {
let slice: Vec<Value> = arr
.iter()
.take(max_list)
.map(|item| {
if SIMPLIFY_REFS.contains(&k.as_str()) {
if let Value::Object(obj) = item {
let mut mini = serde_json::Map::new();
if let Some(q) = obj.get("qname") {
mini.insert("qname".into(), q.clone());
}
if let Some(f) = obj.get("file") {
mini.insert("file".into(), f.clone());
}
Value::Object(mini)
} else {
item.clone()
}
} else {
item.clone()
}
})
.collect();
let truncated = arr.len() > max_list;
if truncated {
Value::Array({
let mut s = slice;
s.push(serde_json::json!(format!(
"... {} more",
arr.len() - max_list
)));
s
})
} else {
Value::Array(slice)
}
} else {
trimmed
}
} else if k == "recently_touched" {
if let Value::Array(files) = &trimmed {
let capped: Vec<Value> = files
.iter()
.take(3)
.map(|file_entry| {
if let Value::Object(obj) = file_entry {
let mut m = obj.clone();
if let Some(Value::Array(commits)) = m.get_mut("commits") {
commits.truncate(max_list);
}
Value::Object(m)
} else {
file_entry.clone()
}
})
.collect();
Value::Array(capped)
} else {
trimmed
}
} else {
trimmed
};
out.insert(k.clone(), final_val);
}
Value::Object(out)
}
Value::Array(arr) => {
Value::Array(arr.iter().map(|i| trim_for_agent(i, max_list)).collect())
}
other => other.clone(),
}
}
pub fn estimate_tokens(json: &str) -> usize {
json.len().saturating_add(3) / 4
}
pub fn parse_intent(s: &str) -> Option<&'static str> {
match s.to_lowercase().as_str() {
"bugfix" => Some("bugfix"),
"feature" => Some("feature"),
"refactor" => Some("refactor"),
"test" => Some("test"),
"architecture" => Some("architecture"),
"ui" => Some("ui"),
_ => None,
}
}
pub fn intent_focus(intent: &str) -> &'static str {
match intent {
"bugfix" => {
"Focus: callers that may be broken, effects, invariants to preserve, affected tests."
}
"feature" => "Focus: callees to extend, ownership boundaries, empty extension points.",
"refactor" => {
"Focus: callers (blast radius), invariants that must hold, ownership constraints."
}
"test" => {
"Focus: affected test symbols, effects under test, existing proof ledger entries."
}
"architecture" => {
"Focus: invariants, ownership boundaries, layer grouping, cross-layer effects."
}
"ui" => "Focus: UI/ViewModel layers, effects that touch display state, scheduler coupling.",
_ => "",
}
}
pub fn intent_layer_order(intent: &str) -> &'static [&'static str] {
match intent {
"ui" => &[
"ui",
"viewmodel",
"scheduler",
"core_model",
"persistence",
"utility",
"tests",
"other",
],
"architecture" => &[
"core_model",
"persistence",
"scheduler",
"viewmodel",
"ui",
"utility",
"tests",
"other",
],
"bugfix" => &[
"core_model",
"scheduler",
"persistence",
"viewmodel",
"ui",
"tests",
"utility",
"other",
],
"test" => &[
"tests",
"core_model",
"scheduler",
"persistence",
"viewmodel",
"ui",
"utility",
"other",
],
_ => &[
"ui",
"viewmodel",
"scheduler",
"core_model",
"persistence",
"utility",
"tests",
"other",
],
}
}
#[derive(Debug, Clone)]
pub struct FileRecency {
pub last_touched_days: Option<f64>,
pub hot: bool,
}
pub fn git_dirty_files() -> std::collections::HashSet<String> {
use std::process::Command as Proc;
let out = Proc::new("git")
.args(["status", "--short", "--untracked-files=no"])
.output();
let Ok(o) = out else {
return std::collections::HashSet::new();
};
if !o.status.success() {
return std::collections::HashSet::new();
}
const SRC_EXTS: &[&str] = &[
".swift", ".py", ".ts", ".tsx", ".js", ".rs", ".go", ".kt", ".java", ".rb", ".cs", ".m",
".mm", ".cpp", ".c",
];
String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| l.len() > 3 && SRC_EXTS.iter().any(|ext| l.ends_with(ext)))
.map(|l| l[3..].trim().to_string())
.collect()
}
pub fn derive_cold_hints(qname: &str, signature: Option<&str>, doc: Option<&str>) -> Vec<String> {
let mut hints: Vec<String> = Vec::new();
if let Some(d) = doc {
let first = d.lines().next().unwrap_or("").trim();
let first = first.split('.').next().unwrap_or("").trim();
if first.len() > 12 {
hints.push(format!("verify: {}", first.to_lowercase()));
}
}
let leaf = qname
.split(|c: char| c == '.' || c == ':' || c == '/')
.last()
.unwrap_or(qname);
let words = split_identifier_words(leaf);
if words.len() >= 2 {
hints.push(format!(
"verify {} behavior",
words.join(" ").to_lowercase()
));
} else if words.len() == 1 && hints.is_empty() {
hints.push(format!("verify {} is correct", words[0].to_lowercase()));
}
if let Some(sig) = signature {
let params = extract_sig_param_names(sig);
if !params.is_empty() && params.len() <= 4 {
hints.push(format!("verify effect of {} on output", params.join(", ")));
}
}
hints.truncate(3);
hints
}
fn split_identifier_words(s: &str) -> Vec<String> {
let mut words: Vec<String> = Vec::new();
let mut cur = String::new();
for ch in s.chars() {
if ch == '_' || ch == '-' {
if !cur.is_empty() {
words.push(cur.clone());
cur.clear();
}
} else if ch.is_uppercase() && !cur.is_empty() {
words.push(cur.clone());
cur.clear();
cur.push(ch);
} else {
cur.push(ch);
}
}
if !cur.is_empty() {
words.push(cur);
}
words.into_iter().filter(|w| w.len() > 1).collect()
}
fn extract_sig_param_names(sig: &str) -> Vec<String> {
let inner = match (sig.find('('), sig.rfind(')')) {
(Some(a), Some(b)) if b > a + 1 => &sig[a + 1..b],
_ => return vec![],
};
let mut names: Vec<String> = Vec::new();
for part in inner.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let token = part
.split(|c: char| c.is_whitespace() || c == ':')
.find(|t| !t.is_empty())
.unwrap_or("")
.trim_start_matches(['_', '&'])
.trim_start_matches("mut ")
.to_string();
if token.len() > 1 && token.chars().all(|c| c.is_alphanumeric() || c == '_') {
names.push(token.to_lowercase());
}
}
names
}
pub fn find_indexed_test_files(db_path: &std::path::Path, source_file: &str) -> Vec<String> {
let stem = std::path::Path::new(source_file)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
if stem.is_empty() {
return vec![];
}
let Ok(db) = SearchFtsDb::open(db_path) else {
return vec![];
};
let Ok(mut stmt) = db
.conn
.prepare("SELECT DISTINCT file_orig FROM asd_search_fts WHERE tier = 2")
else {
return vec![];
};
let rows: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(0))
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default();
rows.into_iter()
.filter(|f| {
let f_stem = std::path::Path::new(f)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
f_stem.contains(&stem)
|| stem.contains(
f_stem
.trim_end_matches("tests")
.trim_end_matches("test")
.trim_end_matches("spec"),
)
})
.collect()
}
pub fn fetch_all_test_file_paths(db_path: &std::path::Path) -> Vec<String> {
let Ok(db) = SearchFtsDb::open(db_path) else {
return vec![];
};
let Ok(mut stmt) = db
.conn
.prepare("SELECT DISTINCT file_orig FROM asd_search_fts WHERE tier = 2")
else {
return vec![];
};
stmt.query_map([], |r| r.get::<_, String>(0))
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
pub fn test_files_for_source(all_test_files: &[String], source_file: &str) -> Vec<String> {
let stem = std::path::Path::new(source_file)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
if stem.is_empty() {
return vec![];
}
all_test_files
.iter()
.filter(|f| {
let f_stem = std::path::Path::new(f.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
f_stem.contains(&stem)
|| stem.contains(
f_stem
.trim_end_matches("tests")
.trim_end_matches("test")
.trim_end_matches("spec"),
)
})
.cloned()
.collect()
}
pub fn propose_test_path(source_file: &str) -> String {
let path = std::path::Path::new(source_file);
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown");
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("swift");
let candidate = source_file
.replace("/Sources/", "/Tests/")
.replace("/Source/", "/Tests/")
.replace("/src/", "/tests/");
let parent = if candidate != source_file {
std::path::Path::new(&candidate)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("Tests")
.to_string()
} else {
"Tests".to_string()
};
format!("{parent}/{stem}Tests.{ext}")
}
pub fn propose_test_stub(source_file: &str, symbol_name: &str) -> String {
let ext = std::path::Path::new(source_file)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let snake = to_snake_case(symbol_name);
let pascal = to_pascal_case(symbol_name);
match ext.as_str() {
"py" => format!(
"def test_{snake}():\n # arrange\n # act\n # assert\n raise NotImplementedError(\"test_{snake}: fill in\")\n"
),
"ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => format!(
"test('{snake}', () => {{\n // arrange\n // act\n // assert\n throw new Error('test {snake}: fill in');\n}});\n"
),
"rs" => format!(
"#[test]\nfn {snake}() {{\n // arrange\n // act\n // assert\n todo!(\"{snake}: fill in\");\n}}\n"
),
"go" => format!(
"func Test{pascal}(t *testing.T) {{\n // arrange\n // act\n // assert\n t.Fatal(\"Test{pascal}: fill in\")\n}}\n"
),
"java" => format!(
"@Test\npublic void test{pascal}() {{\n // arrange\n // act\n // assert\n fail(\"test{pascal}: fill in\");\n}}\n"
),
"cs" => format!(
"[Test]\npublic void {pascal}_Should() {{\n // arrange\n // act\n // assert\n Assert.Fail(\"{pascal}_Should: fill in\");\n}}\n"
),
"rb" => format!(
"def test_{snake}\n # arrange\n # act\n # assert\n flunk(\"test_{snake}: fill in\")\nend\n"
),
"kt" | "kts" => format!(
"@Test\nfun `{snake}`() {{\n // arrange\n // act\n // assert\n fail(\"{snake}: fill in\")\n}}\n"
),
"swift" => format!(
"func test{pascal}() throws {{\n // arrange\n // act\n // assert\n XCTFail(\"test{pascal}: fill in\")\n}}\n"
),
_ => {
format!("// New test exercising {symbol_name} — fill in the arrange / act / assert.\n")
}
}
}
fn to_snake_case(name: &str) -> String {
let leaf = name.rsplit('.').next().unwrap_or(name);
let mut out = String::new();
let mut prev_lower = false;
for ch in leaf.chars() {
if ch == '_' || ch == '-' {
if !out.ends_with('_') {
out.push('_');
}
prev_lower = false;
} else if ch.is_ascii_uppercase() {
if prev_lower && !out.ends_with('_') {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
prev_lower = false;
} else {
out.push(ch);
prev_lower = ch.is_ascii_lowercase() || ch.is_ascii_digit();
}
}
out.trim_matches('_').to_string()
}
fn to_pascal_case(name: &str) -> String {
let leaf = name.rsplit('.').next().unwrap_or(name);
let mut out = String::new();
let mut capitalize_next = true;
for ch in leaf.chars() {
if ch == '_' || ch == '-' {
capitalize_next = true;
} else if capitalize_next {
out.push(ch.to_ascii_uppercase());
capitalize_next = false;
} else {
out.push(ch);
}
}
out
}
pub fn gather_recency(
scan_commits: usize,
hot_days: f64,
) -> std::collections::HashMap<String, FileRecency> {
use std::collections::HashMap;
use std::process::Command;
let output = Command::new("git")
.args([
"log",
&format!("-n{}", scan_commits),
"--pretty=format:%ct",
"--name-only",
])
.output();
let Ok(out) = output else {
return HashMap::new();
};
if !out.status.success() {
return HashMap::new();
}
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as f64)
.unwrap_or(0.0);
let hot_secs = hot_days * 86400.0;
let text = String::from_utf8_lossy(&out.stdout);
let mut current_ts: Option<f64> = None;
let mut map: HashMap<String, FileRecency> = HashMap::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(ts) = trimmed.parse::<f64>() {
current_ts = Some(ts);
} else if let Some(ts) = current_ts {
map.entry(trimmed.to_string()).or_insert_with(|| {
let days = (now_secs - ts) / 86400.0;
FileRecency {
last_touched_days: Some(days.max(0.0)),
hot: (now_secs - ts) <= hot_secs,
}
});
}
}
map
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OwnerSignalSource {
GitBlame,
DocComment,
GitLog,
LedgerTruth,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AnnotatedOwner {
pub name: String,
pub source: OwnerSignalSource,
}
#[derive(Debug, Clone)]
pub struct OwnershipSignal {
pub primary_author: Option<String>,
pub doc_owner: Option<String>,
pub recent_committers: Vec<String>,
pub annotated: Vec<AnnotatedOwner>,
}
pub fn discover_symbol_ownership(
file: &str,
start_line: u32,
end_line: u32,
doc: Option<&str>,
) -> OwnershipSignal {
use std::collections::HashMap;
use std::process::Command;
let doc_owner = doc.and_then(|d| {
for line in d.lines() {
let l = line.trim().to_lowercase();
for prefix in &["@owner ", "owner: ", "owned by "] {
if let Some(rest) = l.strip_prefix(prefix) {
let owner = rest.split_whitespace().next().unwrap_or("").to_string();
if !owner.is_empty() {
return Some(owner);
}
}
}
}
None
});
let blame_out = Command::new("git")
.args([
"blame",
"--porcelain",
&format!("-L{},{}", start_line.max(1), end_line.max(start_line)),
"--",
file,
])
.output();
let mut blame_authors: HashMap<String, usize> = HashMap::new();
if let Ok(out) = blame_out {
if out.status.success() {
for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Some(author) = line.strip_prefix("author ") {
*blame_authors.entry(author.trim().to_string()).or_insert(0) += 1;
}
}
}
}
let primary_author = blame_authors
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(a, _)| a)
.filter(|a| !a.is_empty() && a != "Not Committed Yet");
let log_out = Command::new("git")
.args(["log", "-n20", "--pretty=format:%an", "--", file])
.output();
let mut seen = std::collections::HashSet::new();
let mut recent_committers: Vec<String> = Vec::new();
if let Ok(out) = log_out {
if out.status.success() {
for author in String::from_utf8_lossy(&out.stdout).lines() {
let a = author.trim().to_string();
if !a.is_empty() && seen.insert(a.clone()) {
recent_committers.push(a);
if recent_committers.len() >= 5 {
break;
}
}
}
}
}
let mut annotated: Vec<AnnotatedOwner> = Vec::new();
if let Some(ref owner) = doc_owner {
annotated.push(AnnotatedOwner {
name: owner.clone(),
source: OwnerSignalSource::DocComment,
});
}
if let Some(ref author) = primary_author {
if !annotated.iter().any(|a| &a.name == author) {
annotated.push(AnnotatedOwner {
name: author.clone(),
source: OwnerSignalSource::GitBlame,
});
}
}
for committer in &recent_committers {
if !annotated.iter().any(|a| &a.name == committer) {
annotated.push(AnnotatedOwner {
name: committer.clone(),
source: OwnerSignalSource::GitLog,
});
}
}
OwnershipSignal {
primary_author,
doc_owner,
recent_committers,
annotated,
}
}
#[derive(Debug, Clone)]
pub struct CoveringTest {
pub qname: String,
pub file: String,
pub line: i64,
pub run_command: String,
}
pub fn find_covering_tests(fts: Option<&SearchFtsDb>, impl_qname: &str) -> Vec<CoveringTest> {
let leaf = impl_qname
.split(|c: char| c == '.' || c == ':' || c == '/')
.last()
.unwrap_or(impl_qname)
.to_lowercase();
if leaf.is_empty() || leaf.len() < 3 {
return vec![];
}
let Some(db) = fts else {
return vec![];
};
let Ok(mut stmt) = db
.conn
.prepare("SELECT qname_orig, file_orig, start_line FROM asd_search_fts WHERE tier = 2")
else {
return vec![];
};
let rows: Vec<(String, String, i64)> = stmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
))
})
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default();
rows.into_iter()
.filter(|(qname, _, _)| {
let q = qname.to_lowercase();
let stripped = q
.trim_start_matches("test")
.trim_start_matches('_')
.trim_start_matches("spec")
.trim_start_matches('_');
stripped.contains(leaf.as_str()) || q.contains(leaf.as_str())
})
.map(|(qname, file, line)| {
let run_command = derive_test_run_command(&file, &qname);
CoveringTest {
qname,
file,
line,
run_command,
}
})
.collect()
}
fn derive_test_run_command(file: &str, test_qname: &str) -> String {
use std::path::Path;
let file_path = Path::new(file);
let leaf_name = test_qname
.split(|c: char| c == '.' || c == ':')
.last()
.unwrap_or(test_qname);
let mut dir = file_path.parent();
while let Some(d) = dir {
if d.join("Package.swift").exists() {
return format!("swift test --filter {}", leaf_name);
}
if d.join("Cargo.toml").exists() {
return format!("cargo test {}", leaf_name);
}
if d.join("package.json").exists() {
if file.ends_with(".test.ts")
|| file.ends_with(".spec.ts")
|| file.ends_with(".test.js")
|| file.ends_with(".spec.js")
{
return format!("npx jest --testNamePattern=\"{}\"", leaf_name);
}
return format!("npm test -- --grep \"{}\"", leaf_name);
}
if d.join("pyproject.toml").exists() || d.join("setup.py").exists() {
return format!("pytest -k \"{}\"", leaf_name);
}
if d.join("go.mod").exists() {
let rel = file_path
.strip_prefix(d)
.ok()
.and_then(|p| p.parent())
.and_then(|p| p.to_str())
.map(|s| format!("./{}", s))
.unwrap_or_else(|| "./...".to_string());
return format!("go test {} -run {}", rel, leaf_name);
}
if d.join("Gemfile").exists() {
return format!("bundle exec rspec --example \"{}\"", leaf_name);
}
if d.join("build.gradle").exists() || d.join("build.gradle.kts").exists() {
return format!("./gradlew test --tests \"*{}*\"", leaf_name);
}
if d.join("pom.xml").exists() {
return format!("mvn -Dtest=*{}* test", leaf_name);
}
dir = d.parent();
}
if file.ends_with(".swift") {
format!("swift test --filter {}", leaf_name)
} else if file.ends_with(".rs") {
format!("cargo test {}", leaf_name)
} else if file.ends_with(".py") {
format!("pytest -k \"{}\"", leaf_name)
} else if file.ends_with(".go") {
format!("go test ./... -run {}", leaf_name)
} else {
format!("# run: {}", leaf_name)
}
}
fn strip_doc_prefix(line: &str) -> &str {
let s = line.trim();
for prefix in &["///", "//!", "/**", "*/", "//"] {
if let Some(rest) = s.strip_prefix(prefix) {
return rest
.trim_start_matches([' ', '\t'])
.trim_end_matches([' ', '\t']);
}
}
if s.starts_with("* ") || s == "*" {
return s[1..].trim_start_matches([' ', '\t']);
}
for prefix in &["#", "--"] {
if let Some(rest) = s.strip_prefix(prefix) {
return rest.trim_start_matches([' ', '\t']);
}
}
s
}
pub fn extract_summary(doc: Option<&str>, signature: Option<&str>) -> String {
if let Some(d) = doc {
let cleaned: String = d
.lines()
.map(strip_doc_prefix)
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string();
if !cleaned.is_empty() {
let end = cleaned
.char_indices()
.find_map(|(i, c)| {
if matches!(c, '.' | '!' | '?') {
Some(i + c.len_utf8())
} else {
None
}
})
.unwrap_or(cleaned.len().min(120));
let sentence = cleaned[..end.min(cleaned.len())]
.trim()
.trim_end_matches(['.', '!', '?']);
if !sentence.is_empty() {
return sentence.to_string();
}
}
}
if let Some(sig) = signature {
let trimmed = sig.trim();
if !trimmed.is_empty() {
return trimmed.chars().take(100).collect();
}
}
String::new()
}
pub fn symbol_tier(file: &str) -> SymbolTier {
if is_test_file(file) {
2
} else if is_utility_file(file) {
1
} else {
0
}
}
pub fn load_layer_overrides(db_path: &std::path::Path) -> Vec<(String, String)> {
let candidates = [
db_path.parent().map(|p| p.join(".asd").join("layers.toml")),
db_path.parent().map(|p| p.join("layers.toml")),
];
for maybe_path in candidates.iter().flatten() {
let Ok(text) = std::fs::read_to_string(maybe_path) else {
continue;
};
#[derive(serde::Deserialize)]
struct LayersFile {
patterns: Option<toml::Table>,
}
let Ok(parsed) = toml::from_str::<LayersFile>(&text) else {
continue;
};
let Some(patterns) = parsed.patterns else {
continue;
};
let pairs: Vec<(String, String)> = patterns
.into_iter()
.filter_map(|(k, v)| {
let layer = v.as_str()?;
validate_layer_name(layer).map(|l| (k.to_lowercase(), l.to_string()))
})
.collect();
if !pairs.is_empty() {
return pairs;
}
}
vec![]
}
fn validate_layer_name(name: &str) -> Option<&'static str> {
match name {
"ui" => Some("ui"),
"viewmodel" => Some("viewmodel"),
"scheduler" => Some("scheduler"),
"core_model" => Some("core_model"),
"persistence" => Some("persistence"),
"utility" => Some("utility"),
"tests" => Some("tests"),
"other" => Some("other"),
_ => None,
}
}
pub fn classify_layer(
file: &str,
tier: SymbolTier,
overrides: &[(String, String)],
) -> &'static str {
if tier == 2 {
return "tests";
}
if tier == 1 {
return "utility";
}
let lower = file.to_lowercase();
for (pattern, layer) in overrides {
if lower.contains(pattern.as_str()) {
if let Some(l) = validate_layer_name(layer) {
return l;
}
}
}
let segments: Vec<&str> = lower.split(|c| c == '/' || c == '\\').collect();
let filename = segments.last().copied().unwrap_or("");
let stem = filename.rsplit('.').nth(1).unwrap_or(filename);
let dirs: Vec<&str> = segments.iter().copied().rev().skip(1).collect();
const SCHED_DIRS: &[&str] = &[
"scheduler",
"schedulers",
"engine",
"engines",
"audio",
"audioengine",
"transport",
"render",
"renderer",
"pipeline",
"worker",
"workers",
"processing",
"realtime",
"dsp",
"clock",
];
const SCHED_SUFFIXES: &[&str] = &[
"scheduler",
"engine",
"transport",
"renderer",
"pipeline",
"worker",
"processor",
"synthesizer",
"synth",
"clock",
"timer",
"compiler",
"loop",
];
if dirs.iter().any(|d| SCHED_DIRS.contains(d))
|| SCHED_SUFFIXES.iter().any(|s| stem.ends_with(s))
{
return "scheduler";
}
const PERSIST_DIRS: &[&str] = &[
"storage",
"database",
"db",
"repository",
"repositories",
"cache",
"datastore",
"persistence",
"migration",
"migrations",
"store",
"dao",
];
const PERSIST_SUFFIXES: &[&str] = &[
"repository",
"store",
"database",
"cache",
"storage",
"dao",
"datasource",
"migration",
];
if dirs.iter().any(|d| PERSIST_DIRS.contains(d))
|| PERSIST_SUFFIXES.iter().any(|s| stem.ends_with(s))
{
return "persistence";
}
const VM_DIRS: &[&str] = &[
"viewmodels",
"viewmodel",
"presenters",
"presenter",
"coordinators",
"coordinator",
"interactors",
"interactor",
"controllers",
"controller",
"states",
"state",
"routers",
"router",
];
const VM_SUFFIXES: &[&str] = &[
"viewmodel",
"viewstate",
"presenter",
"coordinator",
"interactor",
"statemanager",
"controller",
"observable",
"environment",
"router",
];
if dirs.iter().any(|d| VM_DIRS.contains(d)) || VM_SUFFIXES.iter().any(|s| stem.ends_with(s)) {
return "viewmodel";
}
const UI_DIRS: &[&str] = &[
"views",
"view",
"screens",
"screen",
"pages",
"page",
"components",
"component",
"widgets",
"widget",
"cells",
"viewcontrollers",
"ui",
"fragments",
];
const UI_SUFFIXES: &[&str] = &[
"view",
"screen",
"page",
"component",
"widget",
"cell",
"viewcontroller",
"fragment",
"layout",
"button",
"label",
"panel",
"sheet",
"modal",
"overlay",
"header",
"footer",
];
if dirs.iter().any(|d| UI_DIRS.contains(d)) || UI_SUFFIXES.iter().any(|s| stem.ends_with(s)) {
return "ui";
}
const MODEL_DIRS: &[&str] = &[
"models", "model", "domain", "core", "entities", "entity", "services", "service",
"usecases", "usecase", "business", "logic", "features", "feature",
];
const MODEL_SUFFIXES: &[&str] = &[
"model",
"entity",
"service",
"usecase",
"manager",
"handler",
"factory",
"builder",
"validator",
];
if dirs.iter().any(|d| MODEL_DIRS.contains(d))
|| MODEL_SUFFIXES.iter().any(|s| stem.ends_with(s))
{
return "core_model";
}
"other"
}
pub fn classify_file_role(file: &str) -> &'static str {
let fl = format!("/{}", file.to_lowercase());
if fl.contains("/test")
|| fl.contains("/tests/")
|| fl.contains("/spec")
|| fl.contains("_test.")
|| fl.contains("spec.")
|| fl.ends_with("tests.swift")
{
return "test";
}
if fl.contains("/example")
|| fl.contains("/examples")
|| fl.contains("/sample")
|| fl.contains("/samples")
|| fl.contains("/demo")
|| fl.contains("/demos")
{
return "example";
}
if fl.contains("/fixture")
|| fl.contains("/fixtures")
|| fl.contains("/seed")
|| fl.contains("/seeds")
{
return "fixture";
}
if fl.contains("/script")
|| fl.contains("/scripts")
|| fl.contains("/tool/")
|| fl.contains("/tools/")
|| fl.contains("/bin/")
|| fl.contains("/hack/")
{
return "script";
}
if fl.contains("/generated")
|| fl.contains("/gen/")
|| fl.contains(".generated.")
|| fl.contains(".pb.")
|| fl.contains("_generated")
{
return "generated";
}
if fl.contains("/doc")
|| fl.contains("/docs")
|| fl.contains("/reference")
|| fl.contains("readme")
|| fl.ends_with(".md")
|| fl.ends_with(".rst")
|| fl.ends_with(".adoc")
{
return "reference";
}
if fl.contains("/viewmodels/") || fl.contains("/viewmodel/") || stem_ends_with(&fl, "viewmodel")
{
return "viewmodel";
}
if fl.contains("/views/")
|| fl.contains("/view/")
|| stem_ends_with(&fl, "view")
|| fl.ends_with(".vue")
|| fl.ends_with(".svelte")
{
return "view";
}
"impl"
}
fn stem_ends_with(path_lower: &str, suffix: &str) -> bool {
let stem = std::path::Path::new(path_lower)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
stem.ends_with(suffix)
}
pub fn classify_layer_sym(
file: &str,
qname: &str,
tier: SymbolTier,
overrides: &[(String, String)],
) -> &'static str {
let layer = classify_layer(file, tier, overrides);
if layer != "other" {
return layer;
}
let components: Vec<&str> = qname
.split(|c| c == '.' || c == ':' || c == '/')
.filter(|s| !s.is_empty())
.collect();
let mut found_viewmodel = false;
let mut found_ui = false;
let mut found_scheduler = false;
let mut found_persistence = false;
let mut found_core_model = false;
for component in &components {
let n = component.to_lowercase();
if n.ends_with("viewmodel")
|| n.ends_with("controller")
|| n.ends_with("presenter")
|| n.ends_with("coordinator")
|| n.ends_with("interactor")
|| n.ends_with("viewstate")
|| n.ends_with("statemanager")
|| n.ends_with("router")
{
found_viewmodel = true;
} else if n.ends_with("view")
|| n.ends_with("screen")
|| n.ends_with("page")
|| n.ends_with("cell")
|| n.ends_with("widget")
|| n.ends_with("button")
|| n.ends_with("label")
|| n.ends_with("panel")
|| n.ends_with("viewcontroller")
|| n.ends_with("sheet")
|| n.ends_with("overlay")
|| n.ends_with("header")
{
found_ui = true;
} else if n.ends_with("scheduler")
|| n.ends_with("engine")
|| n.ends_with("compiler")
|| n.ends_with("processor")
|| n.ends_with("renderer")
|| n.ends_with("clock")
|| n.ends_with("timer")
|| n.ends_with("pipeline")
|| n.ends_with("worker")
|| n.ends_with("synthesizer")
|| n.ends_with("transport")
{
found_scheduler = true;
} else if n.ends_with("repository")
|| n.ends_with("store")
|| n.ends_with("cache")
|| n.ends_with("dao")
|| n.ends_with("database")
|| n.ends_with("datasource")
{
found_persistence = true;
} else if n.ends_with("model")
|| n.ends_with("entity")
|| n.ends_with("service")
|| n.ends_with("manager")
|| n.ends_with("handler")
|| n.ends_with("factory")
|| n.ends_with("validator")
|| n.ends_with("usecase")
|| n.ends_with("builder")
{
found_core_model = true;
}
}
if found_viewmodel {
return "viewmodel";
}
if found_ui {
return "ui";
}
if found_scheduler {
return "scheduler";
}
if found_persistence {
return "persistence";
}
if found_core_model {
return "core_model";
}
"other"
}
fn is_utility_file(file: &str) -> bool {
let lower = file.to_lowercase();
let segments: Vec<&str> = lower.split(|c| c == '/' || c == '\\').collect();
const UTILITY_DIRS: &[&str] = &[
"previews",
"preview",
"samples",
"sample",
"sampledata",
"examples",
"example",
"mocks",
"mock",
"stubs",
"stub",
"fixtures",
"fixture",
"demo",
"demos",
"editor",
"editors",
"generated",
"gen",
"sandbox",
"playground",
"playgrounds",
];
const UTILITY_PREFIXES: &[&str] = &["mock", "stub", "fake", "sample", "preview", "generated"];
if segments
.iter()
.rev()
.skip(1)
.any(|s| UTILITY_DIRS.contains(s) || UTILITY_PREFIXES.iter().any(|p| s.starts_with(p)))
{
return true;
}
if let Some(filename) = segments.last() {
if filename.contains("preview") || filename.contains("_previews") {
return true;
}
if filename.contains("sampledata") || filename.contains("sample_data") {
return true;
}
if filename.contains(".generated.")
|| filename.contains(".g.")
|| filename.ends_with("generated.swift")
{
return true;
}
if filename.starts_with("mock")
|| filename.contains("_mock.")
|| filename.starts_with("stub")
|| filename.contains("_stub.")
|| filename.starts_with("fake")
|| filename.contains("_fake.")
{
return true;
}
if filename.ends_with(".playground") {
return true;
}
}
false
}
fn is_test_file(file: &str) -> bool {
let lower = file.to_lowercase();
let segments: Vec<&str> = lower.split(|c| c == '/' || c == '\\').collect();
const TEST_DIRS: &[&str] = &[
"tests",
"test",
"specs",
"spec",
"__tests__",
"__mocks__",
"testing",
"testcases",
];
if segments.iter().rev().skip(1).any(|s| TEST_DIRS.contains(s)) {
return true;
}
if let Some(filename) = segments.last() {
if filename.starts_with("test_") || filename.ends_with("_test.py") {
return true;
}
if filename.ends_with("_test.go") {
return true;
}
if filename.ends_with("_test.rs") {
return true;
}
if filename.ends_with("tests.swift") || filename.ends_with("spec.swift") {
return true;
}
if filename.contains(".test.") || filename.contains(".spec.") {
return true;
}
if filename.ends_with("_spec.rb") {
return true;
}
if filename.ends_with("test.java")
|| filename.ends_with("tests.java")
|| filename.ends_with("test.kt")
|| filename.ends_with("tests.kt")
{
return true;
}
if filename.ends_with("tests.cs") || filename.ends_with("test.cs") {
return true;
}
}
false
}
fn expand_identifier(qname: &str) -> String {
let mut parts: Vec<String> = vec![qname.to_string()];
for segment in qname.split('.') {
parts.push(segment.to_lowercase());
for word in split_camel(segment) {
let w = word.to_lowercase();
if w.len() >= 2 {
parts.push(w);
}
}
}
parts.dedup();
parts.join(" ")
}
fn expand_text(text: &str) -> String {
let mut words: Vec<String> = vec![text.to_string()];
let tokens: Vec<&str> = text
.split(|c: char| !c.is_alphanumeric())
.filter(|s| s.len() >= 2)
.collect();
for tok in tokens {
words.push(tok.to_lowercase());
for word in split_camel(tok) {
let w = word.to_lowercase();
if w.len() >= 2 {
words.push(w);
}
}
}
words.dedup();
words.join(" ")
}
fn split_camel(s: &str) -> Vec<&str> {
let bytes = s.as_bytes();
let mut starts = vec![0usize];
for i in 1..bytes.len() {
let prev = bytes[i - 1];
let curr = bytes[i];
if curr.is_ascii_uppercase() && (prev.is_ascii_lowercase() || prev.is_ascii_digit()) {
starts.push(i);
}
if i + 1 < bytes.len()
&& prev.is_ascii_uppercase()
&& curr.is_ascii_uppercase()
&& bytes[i + 1].is_ascii_lowercase()
{
starts.push(i);
}
}
starts.push(bytes.len());
starts
.windows(2)
.map(|w| &s[w[0]..w[1]])
.filter(|seg| !seg.is_empty())
.collect()
}
#[derive(Debug, Clone)]
pub struct SearchDoc {
pub doc_id: String,
pub kind: DocKind,
pub path: String,
pub span_start: Option<u32>,
pub title: String,
pub body_text: String,
pub owner_symbol_id: Option<String>,
}
impl SearchDoc {
pub fn new(
kind: DocKind,
path: impl Into<String>,
span_start: Option<u32>,
title: impl Into<String>,
body_text: impl Into<String>,
) -> Self {
let path = path.into();
let span_str = span_start.map(|l| l.to_string()).unwrap_or_default();
let raw = format!("{}:{}", path, span_str);
let mut hash: u64 = 5381;
for b in raw.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(b as u64);
}
let doc_id = format!("doc_{:016x}", hash);
Self {
doc_id,
kind,
path,
span_start,
title: title.into(),
body_text: body_text.into(),
owner_symbol_id: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocKind {
Markdown,
Config, Html,
Css,
Manifest, BuildScript, Other,
}
impl DocKind {
pub fn as_str(self) -> &'static str {
match self {
DocKind::Markdown => "markdown",
DocKind::Config => "config",
DocKind::Html => "html",
DocKind::Css => "css",
DocKind::Manifest => "manifest",
DocKind::BuildScript => "build_script",
DocKind::Other => "other",
}
}
}
#[derive(Debug, Clone)]
pub struct DocHit {
pub bm25_score: f64,
pub doc_id: String,
pub kind: String,
pub path: String,
pub span_start: Option<u32>,
pub title: String,
pub preview: String,
pub owner_symbol_id: Option<String>,
}
pub struct SearchDocsDb {
conn: Connection,
}
impl SearchDocsDb {
const SCHEMA_VER: i64 = 1;
pub fn open(db_path: &Path) -> rusqlite::Result<Self> {
let conn = Connection::open(db_path)?;
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA temp_store=MEMORY;
-- Wait out short-lived writers (e.g. asd-serve holding a sync
-- transaction) instead of failing instantly with SQLITE_BUSY.
-- A failed cache sync used to leave the DB cold forever; the
-- timeout plus the open-time self-heal makes that unreachable.
PRAGMA busy_timeout=5000;",
)?;
let db_bytes = std::fs::metadata(db_path)
.map(|m| m.len() as usize)
.unwrap_or(0);
let mut cache_kb: usize = ((db_bytes * 8 / 10) / 1024).clamp(8_192, 65_536);
let mut mmap_bytes: u64 = 268_435_456;
if let Some(project_dir) = db_path.parent() {
let cfg_path = project_dir.join(".asd").join("config.toml");
if let Ok(raw) = std::fs::read_to_string(&cfg_path) {
if let Ok(table) = raw.parse::<toml::Table>() {
if let Some(perf) = table.get("performance").and_then(|v| v.as_table()) {
if let Some(v) = perf.get("cache_size_kb").and_then(|v| v.as_integer()) {
cache_kb = (v as usize).clamp(1_024, 131_072);
}
if let Some(v) = perf.get("mmap_size_mb").and_then(|v| v.as_integer()) {
mmap_bytes = (v as u64).clamp(64, 4096) * 1024 * 1024;
}
}
}
}
}
conn.execute_batch(&format!(
"PRAGMA mmap_size={mmap_bytes}; PRAGMA cache_size=-{cache_kb};"
))?;
let db = Self { conn };
db.ensure_schema()?;
Ok(db)
}
fn ensure_schema(&self) -> rusqlite::Result<()> {
let current: i64 = self
.conn
.query_row("SELECT version FROM asd_docs_meta LIMIT 1", [], |r| {
r.get(0)
})
.unwrap_or(0);
if current != Self::SCHEMA_VER {
self.conn.execute_batch(
"DROP TABLE IF EXISTS asd_search_docs;
DROP TABLE IF EXISTS asd_docs_meta;",
)?;
}
self.conn.execute_batch(&format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS asd_search_docs USING fts5(
doc_id UNINDEXED,
kind UNINDEXED,
path UNINDEXED,
span_start UNINDEXED,
owner_symbol_id UNINDEXED,
title,
body_text,
tokenize = 'unicode61 remove_diacritics 1'
);
CREATE TABLE IF NOT EXISTS asd_docs_meta (version INTEGER PRIMARY KEY);
INSERT OR IGNORE INTO asd_docs_meta VALUES ({});",
Self::SCHEMA_VER
))
}
pub fn rebuild(&self, docs: &[SearchDoc]) -> rusqlite::Result<()> {
self.conn.execute_batch("DELETE FROM asd_search_docs;")?;
for doc in docs {
self.conn.execute(
"INSERT INTO asd_search_docs(doc_id, kind, path, span_start, owner_symbol_id, title, body_text)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
doc.doc_id,
doc.kind.as_str(),
doc.path,
doc.span_start,
doc.owner_symbol_id,
doc.title,
doc.body_text,
],
)?;
}
Ok(())
}
pub fn count(&self) -> rusqlite::Result<i64> {
self.conn
.query_row("SELECT COUNT(*) FROM asd_search_docs", [], |r| r.get(0))
}
pub fn search(
&self,
tokens: &[String],
limit: usize,
kinds: Option<&[&str]>,
) -> rusqlite::Result<Vec<DocHit>> {
let filtered: Vec<&String> = tokens.iter().filter(|t| !is_stopword(t)).collect();
if filtered.is_empty() {
return Ok(vec![]);
}
let match_expr = filtered
.iter()
.map(|t| format!("\"{}\"", t.replace('"', "")))
.collect::<Vec<_>>()
.join(" OR ");
let kind_filter = kinds
.map(|ks| {
let list = ks
.iter()
.map(|k| format!("'{}'", k))
.collect::<Vec<_>>()
.join(",");
format!(" AND kind IN ({})", list)
})
.unwrap_or_default();
let sql = format!(
"SELECT doc_id, kind, path, span_start, owner_symbol_id, title, body_text,
-bm25(asd_search_docs, 5.0, 3.0) AS score
FROM asd_search_docs
WHERE asd_search_docs MATCH ?1{kind_filter}
ORDER BY score DESC
LIMIT {limit}"
);
let mut stmt = self.conn.prepare(&sql)?;
let hits = stmt
.query_map(params![match_expr], |row| {
let body: String = row.get(6)?;
let preview = body.chars().take(200).collect::<String>();
Ok(DocHit {
doc_id: row.get(0)?,
kind: row.get(1)?,
path: row.get(2)?,
span_start: row.get(3)?,
owner_symbol_id: row.get(4)?,
title: row.get(5)?,
preview,
bm25_score: row.get(7)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(hits)
}
pub fn is_empty(&self) -> bool {
self.count().unwrap_or(0) == 0
}
}
pub fn effect_detail_reason(decl: Option<&crate::schema::EffectDecl>) -> String {
let Some(decl) = decl else {
return "no effects declared".to_string();
};
if decl.declared.is_empty() {
return "no effects declared".to_string();
}
if let Some(rt) = &decl.runtime {
let total = rt.confirmations + rt.contradictions;
if total > 0 {
let verdict = if rt.contradictions == 0 {
"runtime-verified"
} else {
"runtime-contested"
};
return format!(
"{} — confidence {:.2} ({}✓/{}✗ over {} trace{})",
verdict,
rt.confidence(),
rt.confirmations,
rt.contradictions,
total,
if total == 1 { "" } else { "s" },
);
}
}
let Some(ref v) = decl.verification else {
return format!(
"effects declared ({} effect{}) but not verified — run 'asd verify-effects'",
decl.declared.len(),
if decl.declared.len() == 1 { "" } else { "s" }
);
};
match v.status {
crate::schema::VerificationStatus::Ok => {
let source = format!("{:?}", v.by).to_lowercase().replace("_", "-");
let date = v.at.format("%Y-%m-%d").to_string();
format!("ok — verified by {} at {}", source, date)
}
crate::schema::VerificationStatus::Unverified => {
"unverified — verification run did not complete".to_string()
}
crate::schema::VerificationStatus::Mismatch => {
if v.mismatches.is_empty() {
"mismatch — declared effects not observed at runtime".to_string()
} else {
let mm = &v.mismatches[0];
format!("mismatch — {} for {:?}", mm.kind, mm.effect)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn decl_with_runtime(conf: u64, contra: u64, prior: f64) -> crate::schema::EffectDecl {
use crate::schema::*;
EffectDecl {
symbol_id: "s".into(),
declared: vec![Effect::new(EffectCategory::IoNetOut)],
transitive: vec![],
verification: Some(Verification {
by: VerificationSource::RuntimeTracer,
at: chrono::Utc::now(),
status: if contra == 0 {
VerificationStatus::Ok
} else {
VerificationStatus::Mismatch
},
mismatches: vec![],
}),
confidence: Some(RuntimeEvidence::derive_confidence(prior, conf, contra)),
runtime: Some(RuntimeEvidence {
confirmations: conf,
contradictions: contra,
prior,
last_trace_id: Some("trc".into()),
last_observed_at: chrono::Utc::now(),
}),
matched_policy: None,
}
}
#[test]
fn effect_detail_runtime_verified_badge() {
let label = effect_detail_reason(Some(&decl_with_runtime(5, 0, 0.5)));
assert!(label.starts_with("runtime-verified"), "got: {label}");
assert!(label.contains("5✓/0✗"), "got: {label}");
assert!(label.contains("over 5 traces"), "got: {label}");
assert!(label.contains("confidence 0."), "got: {label}");
}
#[test]
fn effect_detail_runtime_contested_when_contradictions() {
let label = effect_detail_reason(Some(&decl_with_runtime(2, 3, 0.5)));
assert!(label.starts_with("runtime-contested"), "got: {label}");
assert!(label.contains("2✓/3✗"), "got: {label}");
}
#[test]
fn effect_detail_single_trace_is_singular() {
let label = effect_detail_reason(Some(&decl_with_runtime(1, 0, 0.5)));
assert!(
label.contains("over 1 trace)"),
"expected singular, got: {label}"
);
}
#[test]
fn effect_detail_runtime_takes_precedence_over_static_label() {
let label = effect_detail_reason(Some(&decl_with_runtime(3, 0, 0.6)));
assert!(
!label.starts_with("ok — verified"),
"static label leaked: {label}"
);
}
#[test]
fn effect_detail_falls_back_to_static_without_runtime() {
use crate::schema::*;
let decl = EffectDecl {
symbol_id: "s".into(),
declared: vec![Effect::new(EffectCategory::IoNetOut)],
transitive: vec![],
verification: Some(Verification {
by: VerificationSource::StaticChecker,
at: chrono::Utc::now(),
status: VerificationStatus::Ok,
mismatches: vec![],
}),
confidence: None,
runtime: None,
matched_policy: None,
};
let label = effect_detail_reason(Some(&decl));
assert!(
label.starts_with("ok — verified by staticchecker"),
"got: {label}"
);
}
#[test]
fn effect_detail_none_and_empty() {
assert_eq!(effect_detail_reason(None), "no effects declared");
}
#[test]
fn index_consistency_consistent_returns_null() {
let v = compute_index_consistency(100, 100);
assert!(v.is_null(), "consistent → Null; got: {v:#?}");
}
#[test]
fn index_consistency_asg_ahead_advises_reindex() {
let v = compute_index_consistency(412, 408);
assert_eq!(v["delta"], 4);
assert_eq!(v["consistent"], false);
let advice = v["advice"].as_str().expect("advice when divergent");
assert!(advice.contains("4 symbols"), "got: {advice}");
assert!(advice.contains("'asd index'"), "got: {advice}");
}
#[test]
fn index_consistency_singular_grammar() {
let v = compute_index_consistency(101, 100);
let advice = v["advice"].as_str().unwrap();
assert!(advice.contains("1 symbol "), "got: {advice}");
assert!(!advice.contains("1 symbols"), "got: {advice}");
}
#[test]
fn index_consistency_fts_ahead_advises_reindex_with_stale_wording() {
let v = compute_index_consistency(100, 103);
assert_eq!(v["delta"], -3);
assert_eq!(v["consistent"], false);
let advice = v["advice"].as_str().unwrap();
assert!(advice.contains("3 stale symbols"), "got: {advice}");
assert!(advice.contains("'asd index'"), "got: {advice}");
}
#[test]
fn index_consistency_handles_empty_repo() {
let v = compute_index_consistency(0, 0);
assert!(v.is_null());
}
#[test]
fn stale_classified_empty_index_is_critical() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("empty.db");
let _ = SearchFtsDb::open(&db_path).unwrap();
let w = stale_warning_classified(&db_path, 3600).expect("empty index must warn");
assert_eq!(w.severity, StaleSeverity::Critical);
assert!(w.message.contains("empty"), "got: {}", w.message);
}
#[test]
fn stale_classified_returns_none_when_no_fts_file() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("does_not_exist_yet.db");
use crate::schema::{Position, Symbol, SymbolKind};
let fts = SearchFtsDb::open(&db_path).unwrap();
let sym = Symbol {
symbol_id: "s1".into(),
symbol_fp: "fp".into(),
qname: "p.f".into(),
language: "python".into(),
kind: SymbolKind::Function,
file: "p.py".into(),
start: Position { line: 1, col: 0 },
end: Position { line: 2, col: 0 },
signature: None,
doc: None,
};
fts.rebuild(&[sym]).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
fts.conn
.execute(
"INSERT OR REPLACE INTO asd_index_meta(key, value) VALUES('indexed_at', ?1)",
rusqlite::params![now.to_string()],
)
.unwrap();
assert!(stale_warning_classified(&db_path, SOFT_STALE_THRESHOLD_SECS).is_none());
}
#[test]
fn stale_classified_soft_when_past_threshold_but_fts_healthy() {
use crate::schema::{Position, Symbol, SymbolKind};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("aged.db");
let fts = SearchFtsDb::open(&db_path).unwrap();
let sym = Symbol {
symbol_id: "s1".into(),
symbol_fp: "fp".into(),
qname: "p.f".into(),
language: "python".into(),
kind: SymbolKind::Function,
file: "p.py".into(),
start: Position { line: 1, col: 0 },
end: Position { line: 2, col: 0 },
signature: None,
doc: None,
};
fts.rebuild(&[sym]).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let two_days_ago = now - 48 * 3600;
fts.conn
.execute(
"INSERT OR REPLACE INTO asd_index_meta(key, value) VALUES('indexed_at', ?1)",
rusqlite::params![two_days_ago.to_string()],
)
.unwrap();
let w = stale_warning_classified(&db_path, SOFT_STALE_THRESHOLD_SECS)
.expect("48h-old at 24h threshold must warn");
assert_eq!(w.severity, StaleSeverity::Soft);
assert!(w.age_secs >= 48 * 3600);
assert_eq!(w.indexed_at, Some(two_days_ago));
}
#[test]
fn stale_classified_severity_serializes_as_snake_case() {
let critical = serde_json::to_value(StaleSeverity::Critical).unwrap();
assert_eq!(critical, serde_json::Value::String("critical".to_string()));
let soft = serde_json::to_value(StaleSeverity::Soft).unwrap();
assert_eq!(soft, serde_json::Value::String("soft".to_string()));
}
#[test]
fn stopword_filtering() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let fts = SearchFtsDb::open(&db_path).unwrap();
use crate::schema::{Position, Symbol, SymbolKind};
let sym = Symbol {
symbol_id: "s1".into(),
symbol_fp: "fp".into(),
qname: "App.Foo.punchIn".into(),
language: "swift".into(),
kind: SymbolKind::Method,
file: "App/Foo.swift".into(),
start: Position { line: 1, col: 0 },
end: Position { line: 5, col: 0 },
signature: Some("func punchIn(over existingClip: Clip)".into()),
doc: None,
};
fts.rebuild(std::slice::from_ref(&sym)).unwrap();
let hits = fts.search("over", &FtsFilters::default(), 10).unwrap();
assert!(
hits.is_empty(),
"'over' is a stopword, should return no hits"
);
let hits2 = fts
.search("playhead over clips", &FtsFilters::default(), 10)
.unwrap();
assert!(
hits2.is_empty(),
"stopword-only overlap should not match punchIn"
);
}
#[test]
fn split_camel_basic() {
assert_eq!(
split_camel("refreshDriftPlayhead"),
vec!["refresh", "Drift", "Playhead"]
);
assert_eq!(
split_camel("DriftSynthPool"),
vec!["Drift", "Synth", "Pool"]
);
assert_eq!(split_camel("XMLParser"), vec!["XML", "Parser"]);
assert_eq!(split_camel("simple"), vec!["simple"]);
}
#[test]
fn expand_identifier_roundtrip() {
let exp = expand_identifier("App.AcmeFlow.refreshDriftPlayhead");
assert!(exp.contains("refreshDriftPlayhead"), "original preserved");
assert!(exp.contains("refresh"), "camel split");
assert!(exp.contains("playhead"), "camel split tail");
assert!(exp.contains("acmeflow"), "segment lowercased");
}
#[test]
fn is_test_file_detection() {
assert!(is_test_file(
"Packages/AudioEngine/Tests/KarplusStrongTests.swift"
));
assert!(is_test_file("tests/test_charge_card.py"));
assert!(is_test_file("src/__tests__/auth.test.ts"));
assert!(is_test_file("payments/test_stripe.py"));
assert!(is_test_file("pkg/payments/charge_test.go"));
assert!(is_test_file("src/auth/auth_spec.rb"));
assert!(!is_test_file("App/AcmeFlow/AcmeFlowApp.swift"));
assert!(!is_test_file("src/payments/charge.py"));
assert!(!is_test_file(
"Packages/AudioEngine/Sources/KarplusStrong.swift"
));
}
#[test]
fn tier_classification() {
assert_eq!(symbol_tier("App/ViewModel/DriftPlayheadViewModel.swift"), 0);
assert_eq!(symbol_tier("src/payments/charge.py"), 0);
assert_eq!(
symbol_tier("Packages/AudioEngine/Sources/KarplusStrong.swift"),
0
);
assert_eq!(symbol_tier("App/Previews/DriftView_Previews.swift"), 1);
assert_eq!(
symbol_tier("App/ViewModel/DriftView_Previews.swift"),
1,
"preview filename"
);
assert_eq!(symbol_tier("Mocks/MockAudioEngine.swift"), 1);
assert_eq!(symbol_tier("App/SampleData/TimelineFixture.swift"), 1);
assert_eq!(symbol_tier("App/Generated/Schema.generated.swift"), 1);
assert_eq!(symbol_tier("Tests/DriftTests/PlayheadTests.swift"), 2);
assert_eq!(symbol_tier("tests/test_charge.py"), 2);
assert_eq!(symbol_tier("pkg/payments/charge_test.go"), 2);
}
#[test]
fn utility_symbols_rank_below_production() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let fts = SearchFtsDb::open(&db_path).unwrap();
use crate::schema::{Position, Symbol, SymbolKind};
let make = |id: &str, qname: &str, file: &str| Symbol {
symbol_id: id.into(),
symbol_fp: format!("fp_{id}"),
qname: qname.into(),
language: "swift".into(),
kind: SymbolKind::Method,
file: file.into(),
start: Position { line: 1, col: 0 },
end: Position { line: 5, col: 0 },
signature: None,
doc: None,
};
let prod = make("prod", "App.VM.refreshDriftPlayhead", "App/ViewModel.swift");
let preview = make(
"preview",
"App.Previews.refreshDriftPlayhead",
"App/Previews/DriftView_Previews.swift",
);
fts.rebuild(&[prod, preview]).unwrap();
let hits = fts
.search("refresh drift playhead", &FtsFilters::default(), 10)
.unwrap();
assert_eq!(hits.len(), 2, "both prod and utility included by default");
assert_eq!(hits[0].symbol_id, "prod", "production ranks before preview");
assert_eq!(hits[0].tier, 0);
assert_eq!(hits[1].tier, 1);
}
#[test]
fn fts_excludes_tests_by_default() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let fts = SearchFtsDb::open(&db_path).unwrap();
use crate::schema::{Position, Symbol, SymbolKind};
let make_sym = |id: &str, qname: &str, file: &str| Symbol {
symbol_id: id.to_string(),
symbol_fp: format!("fp_{id}"),
qname: qname.to_string(),
language: "swift".to_string(),
kind: SymbolKind::Method,
file: file.to_string(),
start: Position { line: 1, col: 0 },
end: Position { line: 10, col: 0 },
signature: None,
doc: None,
};
let prod = make_sym(
"sym_prod",
"App.ViewModel.refreshDriftPlayhead",
"App/ViewModel.swift",
);
let test = make_sym(
"sym_test",
"Tests.DriftTests.testRefreshPlayhead",
"Tests/DriftTests.swift",
);
fts.rebuild(&[prod, test]).unwrap();
assert!(fts.has_data());
let hits = fts.search("playhead", &FtsFilters::default(), 10).unwrap();
assert_eq!(hits.len(), 1, "only prod symbol by default");
assert_eq!(hits[0].symbol_id, "sym_prod");
let hits_all = fts
.search(
"playhead",
&FtsFilters {
include_tests: true,
..Default::default()
},
10,
)
.unwrap();
assert_eq!(hits_all.len(), 2, "both when include_tests");
let hits_test_only = fts
.search(
"playhead",
&FtsFilters {
tests_only: true,
..Default::default()
},
10,
)
.unwrap();
assert_eq!(hits_test_only.len(), 1, "only test symbol when tests_only");
assert_eq!(hits_test_only[0].symbol_id, "sym_test");
let hits_precedence = fts
.search(
"playhead",
&FtsFilters {
tests_only: true,
include_tests: false,
..Default::default()
},
10,
)
.unwrap();
assert_eq!(
hits_precedence.len(),
1,
"tests_only overrides include_tests=false"
);
assert_eq!(hits_precedence[0].symbol_id, "sym_test");
}
#[test]
fn fts_round_trip() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let fts = SearchFtsDb::open(&db_path).unwrap();
use crate::schema::{Position, Symbol, SymbolKind};
let sym = Symbol {
symbol_id: "sym_abc".into(),
symbol_fp: "fp_abc".into(),
qname: "App.ViewModel.refreshDriftPlayhead".into(),
language: "swift".into(),
kind: SymbolKind::Method,
file: "App/ViewModel.swift".into(),
start: Position { line: 10, col: 0 },
end: Position { line: 20, col: 0 },
signature: Some("private func refreshDriftPlayhead()".into()),
doc: Some("Refreshes the drift playhead position".into()),
};
fts.rebuild(std::slice::from_ref(&sym)).unwrap();
assert!(fts.has_data());
let hits = fts.search("playhead", &FtsFilters::default(), 10).unwrap();
assert!(!hits.is_empty(), "should find by qname fragment");
assert_eq!(hits[0].symbol_id, "sym_abc");
assert_eq!(
hits[0].qname, "App.ViewModel.refreshDriftPlayhead",
"orig qname preserved"
);
assert_eq!(hits[0].tier, 0, "production file should be tier 0");
let hits2 = fts
.search("refresh drift", &FtsFilters::default(), 10)
.unwrap();
assert!(!hits2.is_empty(), "should find multi-token");
let hits3 = fts
.search(
"playhead",
&FtsFilters {
language: Some("python".into()),
..Default::default()
},
10,
)
.unwrap();
assert!(hits3.is_empty(), "language filter should exclude swift");
}
#[test]
fn summary_extraction() {
assert_eq!(
extract_summary(
Some(
"Updates drift pad visual playhead from transport state. Called on every render tick."
),
None
),
"Updates drift pad visual playhead from transport state"
);
assert_eq!(
extract_summary(Some("Short summary.\nMore detail here."), None),
"Short summary"
);
assert_eq!(
extract_summary(None, Some("func refreshDriftPlayhead() -> Void")),
"func refreshDriftPlayhead() -> Void"
);
assert_eq!(extract_summary(None, None), "");
assert_eq!(
extract_summary(
Some("/// Updates the drift playhead. Called each frame."),
None
),
"Updates the drift playhead"
);
assert_eq!(
extract_summary(
Some("/// Refreshes visual state.\n/// Must be called on main thread."),
None
),
"Refreshes visual state"
);
assert_eq!(
extract_summary(Some("/** Computes clip boundaries. */"), None),
"Computes clip boundaries"
);
assert_eq!(
extract_summary(Some("# Process the audio buffer."), None),
"Process the audio buffer"
);
}
#[test]
fn layer_classification() {
assert_eq!(
classify_layer("App/Views/DriftPlayheadView.swift", 0, &[]),
"ui"
);
assert_eq!(
classify_layer("App/ViewModel/DriftPlayheadViewModel.swift", 0, &[]),
"viewmodel"
);
assert_eq!(
classify_layer("App/Scheduler/DriftScheduler.swift", 0, &[]),
"scheduler"
);
assert_eq!(
classify_layer("App/Engine/AudioEngine.swift", 0, &[]),
"scheduler"
);
assert_eq!(
classify_layer("App/Storage/ClipRepository.swift", 0, &[]),
"persistence"
);
assert_eq!(
classify_layer("App/Domain/Clip.swift", 0, &[]),
"core_model"
);
assert_eq!(
classify_layer("App/Previews/DriftView_Previews.swift", 1, &[]),
"utility"
);
assert_eq!(classify_layer("Tests/DriftTests.swift", 2, &[]), "tests");
assert_eq!(classify_layer("App/AppDelegate.swift", 0, &[]), "other");
}
#[test]
fn layer_classification_qname_fallback() {
assert_eq!(
classify_layer_sym("App/AcmeFlow.swift", "AcmeFlowViewModel", 0, &[]),
"viewmodel"
);
assert_eq!(
classify_layer_sym("App/AcmeFlow.swift", "AcmeFlowController", 0, &[]),
"viewmodel"
);
assert_eq!(
classify_layer_sym("App/AcmeFlow.swift", "DriftCompiler", 0, &[]),
"scheduler"
);
assert_eq!(
classify_layer_sym("App/AcmeFlow.swift", "ClipStore", 0, &[]),
"persistence"
);
assert_eq!(
classify_layer_sym("App/AcmeFlow.swift", "DriftEngine", 0, &[]),
"scheduler"
);
assert_eq!(
classify_layer_sym("App/Views/DriftView.swift", "DriftViewModel", 0, &[]),
"ui"
);
assert_eq!(
classify_layer_sym("App/AppDelegate.swift", "AppDelegate", 0, &[]),
"other"
);
assert_eq!(
classify_layer_sym(
"App/AcmeFlow.swift",
"AcmeFlowViewModel.refreshDriftPlayhead",
0,
&[]
),
"viewmodel"
);
assert_eq!(
classify_layer_sym("App/AcmeFlow.swift", "DriftCompiler.compile", 0, &[]),
"scheduler"
);
assert_eq!(
classify_layer_sym("App/AcmeFlow.swift", "ClipStore.save", 0, &[]),
"persistence"
);
assert_eq!(
classify_layer_sym("src/drift.rs", "AcmeFlowViewModel::refresh", 0, &[]),
"viewmodel"
);
}
#[test]
fn layer_overrides_respected() {
let overrides = vec![
("custominfra".to_string(), "persistence".to_string()),
("bizlogic".to_string(), "core_model".to_string()),
];
assert_eq!(
classify_layer("App/CustomInfra/Repo.swift", 0, &overrides),
"persistence"
);
assert_eq!(
classify_layer("App/BizLogic/Workflow.swift", 0, &overrides),
"core_model"
);
assert_eq!(classify_layer("App/Views/Foo.swift", 0, &overrides), "ui");
}
#[test]
fn load_layer_overrides_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let overrides = load_layer_overrides(&tmp.path().join("asd.db"));
assert!(overrides.is_empty());
}
#[test]
fn load_layer_overrides_parses_toml() {
let tmp = tempfile::tempdir().unwrap();
let asd_dir = tmp.path().join(".asd");
std::fs::create_dir_all(&asd_dir).unwrap();
std::fs::write(
asd_dir.join("layers.toml"),
"[patterns]\nInfra = \"persistence\"\nBusiness = \"core_model\"\n",
)
.unwrap();
let overrides = load_layer_overrides(&tmp.path().join("asd.db"));
assert_eq!(overrides.len(), 2);
assert!(overrides.contains(&("infra".to_string(), "persistence".to_string())));
assert!(overrides.contains(&("business".to_string(), "core_model".to_string())));
}
#[test]
fn intent_parsing() {
assert_eq!(parse_intent("bugfix"), Some("bugfix"));
assert_eq!(parse_intent("ARCHITECTURE"), Some("architecture"));
assert_eq!(parse_intent("ui"), Some("ui"));
assert_eq!(parse_intent("typo"), None);
for i in &[
"bugfix",
"feature",
"refactor",
"test",
"architecture",
"ui",
] {
assert!(!intent_focus(i).is_empty(), "no focus for {i}");
}
for i in &[
"bugfix",
"feature",
"refactor",
"test",
"architecture",
"ui",
"",
] {
assert_eq!(intent_layer_order(i).len(), 8);
}
}
#[test]
fn gather_recency_no_crash_outside_repo() {
let result = gather_recency(10, 14.0);
for (file, rec) in &result {
assert!(!file.is_empty());
if let Some(days) = rec.last_touched_days {
assert!(days >= 0.0);
}
}
}
#[test]
fn symbols_with_constraint_penalties_via_sql_returns_matching_ids() {
use crate::schema::{Author, AuthorKind, LedgerEntry, LedgerKind};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let fts = SearchFtsDb::open(&db_path).unwrap();
let make = |sym_id: &str, kind: LedgerKind, role: Option<&str>| {
let mut e = LedgerEntry::new(
sym_id,
kind,
"test entry",
Author {
kind: AuthorKind::Agent,
id: "t".into(),
},
);
e.role = role.map(str::to_string);
e
};
for (sym_id, kind, role) in [
("sym_a", LedgerKind::Constraint, Some("stale-api")),
("sym_b", LedgerKind::Decision, Some("audit-pending")),
("sym_c", LedgerKind::Hazard, Some("stale-api")),
("sym_d", LedgerKind::Constraint, Some("fast-test")),
("sym_e", LedgerKind::Constraint, None),
] {
fts.upsert_ledger_entry(&make(sym_id, kind, role), "main")
.unwrap();
}
let mut ids = fts.symbols_with_constraint_penalties("main").unwrap();
ids.sort();
assert_eq!(ids, vec!["sym_a".to_string(), "sym_b".to_string()]);
}
#[test]
fn symbols_with_constraint_penalties_returns_empty_when_cache_empty() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let fts = SearchFtsDb::open(&db_path).unwrap();
let ids = fts.symbols_with_constraint_penalties("main").unwrap();
assert!(ids.is_empty());
}
}
#[cfg(test)]
mod plan_j_t007_test_stub_tests {
use super::propose_test_stub;
fn assert_contains_all(actual: &str, needles: &[&str]) {
for n in needles {
assert!(actual.contains(n), "stub missing `{n}` — got:\n{actual}");
}
}
#[test]
fn python_stub_uses_snake_case_def_and_raises_until_filled() {
let s = propose_test_stub("src/billing/calc.py", "discount");
assert_contains_all(
&s,
&[
"def test_discount",
"arrange",
"act",
"assert",
"NotImplementedError",
],
);
}
#[test]
fn typescript_stub_uses_test_function_and_throws() {
let s = propose_test_stub("src/billing/calc.ts", "discount");
assert_contains_all(&s, &["test('discount'", "throw new Error"]);
}
#[test]
fn rust_stub_uses_test_attribute_and_todo_macro() {
let s = propose_test_stub("src/billing/calc.rs", "discount");
assert_contains_all(&s, &["#[test]", "fn discount", "todo!"]);
}
#[test]
fn go_stub_uses_pascal_case_and_t_fatal() {
let s = propose_test_stub("billing/calc.go", "discount");
assert_contains_all(&s, &["func TestDiscount", "*testing.T", "t.Fatal"]);
}
#[test]
fn java_stub_uses_test_annotation_and_fail() {
let s = propose_test_stub("Billing.java", "discount");
assert_contains_all(&s, &["@Test", "testDiscount", "fail("]);
}
#[test]
fn csharp_stub_uses_pascal_case_and_assert_fail() {
let s = propose_test_stub("Billing.cs", "discount");
assert_contains_all(&s, &["[Test]", "Discount_Should", "Assert.Fail"]);
}
#[test]
fn ruby_stub_uses_def_test_and_flunk() {
let s = propose_test_stub("billing.rb", "discount");
assert_contains_all(&s, &["def test_discount", "flunk"]);
}
#[test]
fn kotlin_stub_uses_test_annotation_and_backticks() {
let s = propose_test_stub("Billing.kt", "discount");
assert_contains_all(&s, &["@Test", "`discount`", "fail("]);
}
#[test]
fn swift_stub_uses_pascal_case_and_xctfail() {
let s = propose_test_stub("Billing.swift", "discount");
assert_contains_all(&s, &["func testDiscount", "XCTFail"]);
}
#[test]
fn unknown_extension_falls_back_to_generic_comment() {
let s = propose_test_stub("data.bin", "discount");
assert!(s.contains("discount"));
assert!(s.starts_with("// "));
}
#[test]
fn qname_prefix_is_stripped_from_test_name() {
let s = propose_test_stub("src/calc.py", "billing.payment.charge");
assert!(s.contains("def test_charge"), "got:\n{s}");
assert!(
!s.contains("billing_payment"),
"qname module path must not leak into test name; got:\n{s}"
);
}
#[test]
fn camel_case_input_becomes_snake_for_python() {
let s = propose_test_stub("src/calc.py", "applyDiscount");
assert!(s.contains("def test_apply_discount"), "got:\n{s}");
}
}
#[cfg(test)]
mod plan_j_t003_classify_file_role_tests {
use super::classify_file_role;
#[test]
fn impl_is_default_when_nothing_matches() {
assert_eq!(classify_file_role("src/lib.rs"), "impl");
assert_eq!(classify_file_role("crates/foo/src/main.py"), "impl");
}
#[test]
fn tests_short_circuit_before_view_check() {
assert_eq!(
classify_file_role("App/Tests/AcmeFlowViewTests.swift"),
"test"
);
assert_eq!(classify_file_role("src/lib_test.py"), "test");
assert_eq!(classify_file_role("crates/x/tests/it.rs"), "test");
}
#[test]
fn viewmodel_by_filename_suffix() {
assert_eq!(
classify_file_role("App/Sources/AcmeFlowViewModel.swift"),
"viewmodel"
);
assert_eq!(
classify_file_role("web/src/PlaybackViewModel.ts"),
"viewmodel"
);
}
#[test]
fn viewmodel_by_path_pattern() {
assert_eq!(
classify_file_role("App/Sources/viewmodels/AcmeFlow.swift"),
"viewmodel"
);
assert_eq!(classify_file_role("web/viewmodel/foo.ts"), "viewmodel");
}
#[test]
fn view_by_filename_suffix() {
assert_eq!(classify_file_role("App/Sources/AcmeFlowView.swift"), "view");
assert_eq!(classify_file_role("web/src/TrackView.tsx"), "view");
}
#[test]
fn view_by_path_pattern() {
assert_eq!(
classify_file_role("App/Sources/views/AcmeFlow.swift"),
"view"
);
assert_eq!(classify_file_role("web/view/index.ts"), "view");
}
#[test]
fn view_by_extension() {
assert_eq!(classify_file_role("web/src/App.vue"), "view");
assert_eq!(classify_file_role("web/src/App.svelte"), "view");
}
#[test]
fn viewmodel_short_circuits_before_view() {
let r = classify_file_role("App/Sources/AcmeFlowViewModel.swift");
assert_eq!(r, "viewmodel", "ViewModel must NOT fall to `view`; got {r}");
}
#[test]
fn preview_does_not_match_view_in_middle_of_segment() {
assert_eq!(
classify_file_role("src/PreviewService.swift"),
"impl",
"PreviewService.swift must NOT be a view"
);
}
#[test]
fn existing_roles_still_classify_correctly() {
assert_eq!(
classify_file_role("examples/sample-py-repo/foo.py"),
"example"
);
assert_eq!(classify_file_role("fixtures/seed.json"), "fixture");
assert_eq!(classify_file_role("scripts/release.sh"), "script");
assert_eq!(classify_file_role("generated/proto.pb.go"), "generated");
assert_eq!(classify_file_role("docs/architecture.md"), "reference");
}
}