1use std::collections::HashMap;
31use std::path::Path;
32
33use chrono::{DateTime, Utc};
34use rusqlite::{Connection, params};
35
36use crate::schema::{EffectDecl, FeedbackEntry, FeedbackVerdict, LedgerEntry, Symbol};
37
38pub const STOPWORDS: &[&str] = &[
53 "a", "an", "and", "as", "at", "be", "but", "by", "for", "from", "if", "into", "is", "it",
54 "nor", "not", "of", "on", "or", "so", "the", "to", "via", "vs", "yet", "with", "over", "about",
55 "between", "than", "that", "this", "are", "was", "were", "has", "have", "had", "its", "our",
56];
57
58#[inline]
60pub fn is_stopword(token: &str) -> bool {
61 STOPWORDS.contains(&token)
62}
63
64pub type SymbolTier = u8;
76
77#[derive(Debug, Clone)]
79pub struct FtsHit {
80 pub bm25_score: f64,
82 pub symbol_id: String,
83 pub qname: String,
85 pub kind: String,
86 pub language: String,
87 pub file: String,
89 pub line: u32,
90 pub signature: Option<String>,
92 pub doc: Option<String>,
93 pub tier: SymbolTier,
95 pub ledger_text: String,
99 pub ledger_flags: String,
102}
103
104impl FtsHit {
105 #[inline]
107 pub fn has_ownership(&self) -> bool {
108 self.ledger_flags.contains("ownership")
109 }
110 #[inline]
112 pub fn has_invariant(&self) -> bool {
113 self.ledger_flags.contains("invariant")
114 }
115 #[inline]
117 pub fn has_hazard(&self) -> bool {
118 self.ledger_flags.contains("hazard")
119 }
120 #[inline]
122 pub fn has_ledger(&self) -> bool {
123 !self.ledger_text.is_empty()
124 }
125}
126
127#[derive(Debug, Clone)]
133pub struct SymbolMeta {
134 pub symbol_id: String,
135 pub file: String,
136 pub kind: String,
137}
138
139#[derive(Debug, Clone, Default)]
146pub struct ResolvedSymbol {
147 pub symbol_id: String,
148 pub qname: String,
150 pub file: String,
151 pub kind: String,
152 pub doc: Option<String>,
153 pub signature: Option<String>,
154}
155
156#[derive(Debug, Default, Clone)]
158pub struct FtsFilters {
159 pub kind: Option<String>,
160 pub language: Option<String>,
161 pub include_tests: bool,
165 pub tests_only: bool,
169 pub exclude_terms: Vec<String>,
172 pub paths_filter: Vec<String>,
176 pub exclude_paths: Vec<String>,
181 pub exclude_languages: Vec<String>,
187}
188
189fn tests_clause(include_tests: bool, tests_only: bool) -> &'static str {
194 if tests_only {
195 "AND tier = '2'"
196 } else if include_tests {
197 ""
198 } else {
199 "AND tier != '2'"
200 }
201}
202
203pub struct SearchFtsDb {
208 conn: Connection,
209}
210
211impl SearchFtsDb {
212 pub fn open(db_path: &Path) -> rusqlite::Result<Self> {
224 let conn = Connection::open(db_path)?;
225 conn.execute_batch(
229 "PRAGMA journal_mode=WAL;
230 PRAGMA synchronous=NORMAL;
231 PRAGMA temp_store=MEMORY;
232 -- Wait out short-lived writers (e.g. asd-serve holding a sync
233 -- transaction) instead of failing instantly with SQLITE_BUSY.
234 -- A failed cache sync used to leave the DB cold forever; the
235 -- timeout plus the open-time self-heal makes that unreachable.
236 PRAGMA busy_timeout=5000;",
237 )?;
238
239 let db_bytes = std::fs::metadata(db_path)
242 .map(|m| m.len() as usize)
243 .unwrap_or(0);
244 let mut cache_kb: usize = ((db_bytes * 8 / 10) / 1024).clamp(8_192, 65_536);
245 let mut mmap_bytes: u64 = 268_435_456; if let Some(project_dir) = db_path.parent() {
250 let cfg_path = project_dir.join(".asd").join("config.toml");
251 if let Ok(raw) = std::fs::read_to_string(&cfg_path) {
252 if let Ok(table) = raw.parse::<toml::Table>() {
253 if let Some(perf) = table.get("performance").and_then(|v| v.as_table()) {
254 if let Some(v) = perf.get("cache_size_kb").and_then(|v| v.as_integer()) {
255 cache_kb = (v as usize).clamp(1_024, 131_072);
256 }
257 if let Some(v) = perf.get("mmap_size_mb").and_then(|v| v.as_integer()) {
258 mmap_bytes = (v as u64).clamp(64, 4096) * 1024 * 1024;
259 }
260 }
261 }
262 }
263 }
264
265 conn.execute_batch(&format!(
266 "PRAGMA mmap_size={mmap_bytes}; PRAGMA cache_size=-{cache_kb};"
267 ))?;
268 let db = Self { conn };
269 db.ensure_schema()?;
270 Ok(db)
271 }
272
273 fn ensure_schema(&self) -> rusqlite::Result<()> {
274 const SCHEMA_VER: i64 = 5;
279
280 let current: i64 = self
281 .conn
282 .query_row("SELECT version FROM asd_fts_meta LIMIT 1", [], |r| r.get(0))
283 .unwrap_or(0);
284
285 if current != SCHEMA_VER {
286 self.conn.execute_batch(
287 "DROP TABLE IF EXISTS asd_search_fts;
288 DROP TABLE IF EXISTS asd_search_meta;
289 DROP TABLE IF EXISTS asd_fts_meta;
290 DROP TABLE IF EXISTS asd_symbols_meta;",
291 )?;
292 }
293
294 self.conn.execute_batch(
298 "CREATE TABLE IF NOT EXISTS asd_index_meta (
299 key TEXT PRIMARY KEY,
300 value TEXT NOT NULL
301 );",
302 )?;
303
304 self.conn.execute_batch(
311 "CREATE TABLE IF NOT EXISTS asd_ledger_cache (
312 entry_id TEXT NOT NULL,
313 symbol_id TEXT NOT NULL,
314 ref_name TEXT NOT NULL,
315 body TEXT NOT NULL,
316 PRIMARY KEY (entry_id, ref_name)
317 );
318 CREATE INDEX IF NOT EXISTS idx_asd_lc_sym
319 ON asd_ledger_cache(symbol_id, ref_name);",
320 )?;
321
322 self.conn.execute_batch(
325 "CREATE TABLE IF NOT EXISTS asd_effects_cache (
326 symbol_id TEXT NOT NULL,
327 ref_name TEXT NOT NULL,
328 body TEXT NOT NULL,
329 PRIMARY KEY (symbol_id, ref_name)
330 );",
331 )?;
332
333 self.conn.execute_batch(
339 "CREATE TABLE IF NOT EXISTS asd_feedback (
340 entry_id TEXT PRIMARY KEY,
341 symbol_id TEXT NOT NULL,
342 symbol_qname TEXT NOT NULL,
343 query TEXT NOT NULL,
344 verdict TEXT NOT NULL,
345 author TEXT NOT NULL,
346 created_at TEXT NOT NULL,
347 note TEXT,
348 file_scope TEXT,
349 expires_at TEXT
350 );
351 CREATE INDEX IF NOT EXISTS idx_asd_fb_symbol ON asd_feedback(symbol_id);
352 CREATE INDEX IF NOT EXISTS idx_asd_fb_qname ON asd_feedback(symbol_qname);
353 CREATE INDEX IF NOT EXISTS idx_asd_fb_verdict ON asd_feedback(verdict);",
354 )?;
355 let _ = self
360 .conn
361 .execute("ALTER TABLE asd_feedback ADD COLUMN expires_at TEXT", []);
362 for col in ["withdrawn_at", "withdrawn_by", "withdrawn_reason"] {
365 let _ = self.conn.execute(
366 &format!("ALTER TABLE asd_feedback ADD COLUMN {col} TEXT"),
367 [],
368 );
369 }
370
371 self.conn.execute_batch(
375 "CREATE TABLE IF NOT EXISTS asd_symbols_cache (
376 symbol_id TEXT NOT NULL,
377 ref_name TEXT NOT NULL,
378 qname TEXT NOT NULL,
379 file TEXT NOT NULL,
380 kind TEXT NOT NULL DEFAULT '',
381 symbol_json TEXT NOT NULL,
382 PRIMARY KEY (symbol_id, ref_name)
383 );
384 CREATE INDEX IF NOT EXISTS idx_asd_sc_qname
385 ON asd_symbols_cache(qname, ref_name);
386 -- asd_call_edges: directed caller/callee edges.
387 -- direction is 'caller' (this symbol is called by neighbor)
388 -- or 'callee' (this symbol calls neighbor).
389 CREATE TABLE IF NOT EXISTS asd_call_edges (
390 symbol_id TEXT NOT NULL,
391 neighbor_id TEXT NOT NULL,
392 direction TEXT NOT NULL,
393 ref_name TEXT NOT NULL,
394 PRIMARY KEY (symbol_id, neighbor_id, direction, ref_name)
395 );
396 CREATE INDEX IF NOT EXISTS idx_asd_ce_lookup
397 ON asd_call_edges(symbol_id, direction, ref_name);",
398 )?;
399
400 self.conn.execute_batch(&format!(
401 "CREATE VIRTUAL TABLE IF NOT EXISTS asd_search_fts USING fts5(
402 symbol_id UNINDEXED,
403 qname,
404 signature,
405 doc,
406 file,
407 language UNINDEXED,
408 kind UNINDEXED,
409 line UNINDEXED,
410 qname_orig UNINDEXED,
411 sig_orig UNINDEXED,
412 file_orig UNINDEXED,
413 tier UNINDEXED,
414 ledger_text UNINDEXED,
415 ledger_flags UNINDEXED,
416 tokenize = 'unicode61 remove_diacritics 1'
417 );
418 CREATE TABLE IF NOT EXISTS asd_search_meta (
419 file TEXT PRIMARY KEY,
420 indexed_at INTEGER NOT NULL
421 );
422 -- Lightweight qname→(symbol_id, file, kind) lookup table.
423 -- Populated alongside FTS rebuild; allows feedback functions to resolve
424 -- qname without traversing the git object store.
425 CREATE TABLE IF NOT EXISTS asd_symbols_meta (
426 qname TEXT PRIMARY KEY,
427 symbol_id TEXT NOT NULL,
428 file TEXT NOT NULL,
429 kind TEXT NOT NULL
430 );
431 CREATE TABLE IF NOT EXISTS asd_fts_meta (version INTEGER PRIMARY KEY);
432 INSERT OR IGNORE INTO asd_fts_meta VALUES ({SCHEMA_VER});"
433 ))
434 }
435
436 pub fn rebuild(&self, symbols: &[Symbol]) -> rusqlite::Result<()> {
446 self.rebuild_refs(&symbols.iter().collect::<Vec<_>>(), &HashMap::new())
447 }
448
449 pub fn rebuild_refs(
455 &self,
456 symbols: &[&Symbol],
457 ledger_data: &HashMap<String, (String, String)>,
458 ) -> rusqlite::Result<()> {
459 self.conn.execute_batch(
460 "DELETE FROM asd_search_fts; DELETE FROM asd_search_meta; DELETE FROM asd_symbols_meta;",
461 )?;
462 for sym in symbols {
463 let (lt, lf) = ledger_data
464 .get(&sym.symbol_id)
465 .map(|(t, f)| (t.as_str(), f.as_str()))
466 .unwrap_or(("", ""));
467 self.insert_symbol(sym, lt, lf)?;
468 }
469
470 for sym in symbols {
474 let kind = format!("{:?}", sym.kind).to_lowercase();
475 self.conn.execute(
476 "INSERT OR REPLACE INTO asd_symbols_meta(qname, symbol_id, file, kind)
477 VALUES(?1, ?2, ?3, ?4)",
478 params![sym.qname, sym.symbol_id, sym.file, kind],
479 )?;
480 }
481
482 let now = std::time::SystemTime::now()
484 .duration_since(std::time::UNIX_EPOCH)
485 .map(|d| d.as_secs() as i64)
486 .unwrap_or(0);
487 self.conn.execute(
488 "INSERT OR REPLACE INTO asd_index_meta (key, value) VALUES ('indexed_at', ?1)",
489 params![now.to_string()],
490 )?;
491 Ok(())
492 }
493
494 pub fn last_indexed_at(&self) -> Option<i64> {
497 self.conn
498 .query_row(
499 "SELECT value FROM asd_index_meta WHERE key = 'indexed_at' LIMIT 1",
500 [],
501 |r| r.get::<_, String>(0),
502 )
503 .ok()
504 .and_then(|s| s.parse().ok())
505 }
506
507 pub fn fts_last_rebuild_ok(&self) -> Option<bool> {
511 self.conn
512 .query_row(
513 "SELECT value FROM asd_index_meta WHERE key = 'fts_last_rebuild_ok' LIMIT 1",
514 [],
515 |r| r.get::<_, String>(0),
516 )
517 .ok()
518 .map(|s| s == "1")
519 }
520
521 pub fn mark_symbols_indexed(&self, fts_succeeded: bool) -> rusqlite::Result<()> {
527 let now = std::time::SystemTime::now()
528 .duration_since(std::time::UNIX_EPOCH)
529 .map(|d| d.as_secs() as i64)
530 .unwrap_or(0);
531 self.conn.execute(
532 "INSERT OR REPLACE INTO asd_index_meta (key, value) VALUES ('symbols_indexed_at', ?1)",
533 params![now.to_string()],
534 )?;
535 self.conn.execute(
536 "INSERT OR REPLACE INTO asd_index_meta (key, value) \
537 VALUES ('fts_last_rebuild_ok', ?1)",
538 params![if fts_succeeded { "1" } else { "0" }],
539 )?;
540 Ok(())
541 }
542
543 pub fn fts_symbol_row_count(&self) -> i64 {
553 self.conn
554 .query_row("SELECT COUNT(*) FROM asd_search_fts", [], |r| r.get(0))
555 .unwrap_or(0)
556 }
557
558 pub fn symbols_cached_for(&self, ref_name: &str) -> bool {
562 self.conn
563 .query_row(
564 "SELECT COUNT(*) FROM asd_symbols_cache WHERE ref_name = ?1 LIMIT 1",
565 params![ref_name],
566 |r| r.get::<_, i64>(0),
567 )
568 .map(|n| n > 0)
569 .unwrap_or(false)
570 }
571
572 pub fn sync_symbols(
575 &self,
576 symbols: &[&crate::schema::Symbol],
577 ref_name: &str,
578 ) -> rusqlite::Result<()> {
579 let tx = self.conn.unchecked_transaction()?;
580 tx.execute(
581 "DELETE FROM asd_symbols_cache WHERE ref_name = ?1",
582 params![ref_name],
583 )?;
584 {
585 let mut stmt = tx.prepare(
586 "INSERT OR REPLACE INTO asd_symbols_cache
587 (symbol_id, ref_name, qname, file, kind, symbol_json)
588 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
589 )?;
590 for sym in symbols {
591 let json = serde_json::to_string(sym).unwrap_or_default();
592 let kind = format!("{:?}", sym.kind).to_lowercase();
593 stmt.execute(params![
594 sym.symbol_id,
595 ref_name,
596 sym.qname,
597 sym.file,
598 kind,
599 json
600 ])?;
601 }
602 }
603 tx.commit()
604 }
605
606 pub fn sync_call_edges(
609 &self,
610 callees_of: &HashMap<String, Vec<String>>,
611 callers_of: &HashMap<String, Vec<String>>,
612 ref_name: &str,
613 ) -> rusqlite::Result<()> {
614 let tx = self.conn.unchecked_transaction()?;
615 tx.execute(
616 "DELETE FROM asd_call_edges WHERE ref_name = ?1",
617 params![ref_name],
618 )?;
619 {
620 let mut stmt = tx.prepare(
621 "INSERT OR REPLACE INTO asd_call_edges
622 (symbol_id, neighbor_id, direction, ref_name)
623 VALUES (?1, ?2, ?3, ?4)",
624 )?;
625 for (caller_id, callee_ids) in callees_of {
626 for callee_id in callee_ids {
627 stmt.execute(params![caller_id, callee_id, "callee", ref_name])?;
628 }
629 }
630 for (callee_id, caller_ids) in callers_of {
631 for caller_id in caller_ids {
632 stmt.execute(params![callee_id, caller_id, "caller", ref_name])?;
633 }
634 }
635 }
636 tx.commit()
637 }
638
639 pub fn build_id_map_cached(&self, ref_name: &str) -> HashMap<String, crate::schema::Symbol> {
642 let mut stmt = match self
643 .conn
644 .prepare("SELECT symbol_id, symbol_json FROM asd_symbols_cache WHERE ref_name = ?1")
645 {
646 Ok(s) => s,
647 Err(_) => return HashMap::new(),
648 };
649 let rows = match stmt.query_map(params![ref_name], |row| {
650 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
651 }) {
652 Ok(r) => r,
653 Err(_) => return HashMap::new(),
654 };
655 let mut map = HashMap::new();
656 for row in rows.flatten() {
657 let (id, json) = row;
658 if let Ok(sym) = serde_json::from_str::<crate::schema::Symbol>(&json) {
659 map.insert(id, sym);
660 }
661 }
662 map
663 }
664
665 pub fn files_for_qnames(
675 &self,
676 qnames: &[&str],
677 _ref_name: &str,
678 ) -> std::collections::HashMap<String, String> {
679 let mut out = std::collections::HashMap::with_capacity(qnames.len());
680 if qnames.is_empty() {
681 return out;
682 }
683 let placeholders: Vec<&str> = (0..qnames.len()).map(|_| "?").collect();
684 let sql = format!(
685 "SELECT qname, file FROM asd_symbols_meta WHERE qname IN ({})",
686 placeholders.join(", ")
687 );
688 let mut stmt = match self.conn.prepare(&sql) {
689 Ok(s) => s,
690 Err(_) => return out,
691 };
692 let rows = stmt.query_map(
693 rusqlite::params_from_iter(qnames.iter().map(|q| q as &dyn rusqlite::ToSql)),
694 |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
695 );
696 if let Ok(rows) = rows {
697 for r in rows.flatten() {
698 out.insert(r.0, r.1);
699 }
700 }
701 out
702 }
703
704 pub fn get_symbol_by_qname_cached(
706 &self,
707 qname: &str,
708 ref_name: &str,
709 ) -> Option<crate::schema::Symbol> {
710 self.conn
711 .query_row(
712 "SELECT symbol_json FROM asd_symbols_cache
713 WHERE qname = ?1 AND ref_name = ?2 LIMIT 1",
714 params![qname, ref_name],
715 |r| r.get::<_, String>(0),
716 )
717 .ok()
718 .and_then(|json| serde_json::from_str(&json).ok())
719 }
720
721 pub fn get_neighbors_cached(
724 &self,
725 symbol_id: &str,
726 direction: &str,
727 ref_name: &str,
728 ) -> Vec<String> {
729 let mut stmt = match self.conn.prepare(
730 "SELECT neighbor_id FROM asd_call_edges
731 WHERE symbol_id = ?1 AND direction = ?2 AND ref_name = ?3",
732 ) {
733 Ok(s) => s,
734 Err(_) => return Vec::new(),
735 };
736 stmt.query_map(params![symbol_id, direction, ref_name], |r| {
737 r.get::<_, String>(0)
738 })
739 .map(|rows| rows.flatten().collect())
740 .unwrap_or_default()
741 }
742
743 pub fn build_edge_maps_cached(
749 &self,
750 ref_name: &str,
751 ) -> (HashMap<String, Vec<String>>, HashMap<String, Vec<String>>) {
752 let mut callers_of: HashMap<String, Vec<String>> = HashMap::new();
753 let mut callees_of: HashMap<String, Vec<String>> = HashMap::new();
754 let mut stmt = match self.conn.prepare(
755 "SELECT symbol_id, neighbor_id, direction FROM asd_call_edges
756 WHERE ref_name = ?1",
757 ) {
758 Ok(s) => s,
759 Err(_) => return (callers_of, callees_of),
760 };
761 let rows = match stmt.query_map(params![ref_name], |r| {
762 Ok((
763 r.get::<_, String>(0)?,
764 r.get::<_, String>(1)?,
765 r.get::<_, String>(2)?,
766 ))
767 }) {
768 Ok(r) => r,
769 Err(_) => return (callers_of, callees_of),
770 };
771 for (symbol_id, neighbor_id, direction) in rows.flatten() {
772 match direction.as_str() {
773 "caller" => callers_of.entry(symbol_id).or_default().push(neighbor_id),
774 "callee" => callees_of.entry(symbol_id).or_default().push(neighbor_id),
775 _ => {}
776 }
777 }
778 (callers_of, callees_of)
779 }
780
781 pub fn symbol_count(&self) -> usize {
783 self.conn
784 .query_row("SELECT COUNT(*) FROM asd_search_fts", [], |r| {
785 r.get::<_, i64>(0)
786 })
787 .map(|n| n as usize)
788 .unwrap_or(0)
789 }
790
791 pub fn annotated_symbol_count(&self, ref_name: &str) -> usize {
808 self.conn
809 .query_row(
810 "SELECT COUNT(DISTINCT symbol_id) FROM asd_ledger_cache WHERE ref_name = ?1",
811 params![ref_name],
812 |r| r.get::<_, i64>(0),
813 )
814 .map(|n| n as usize)
815 .unwrap_or(0)
816 }
817
818 fn insert_symbol(
819 &self,
820 sym: &Symbol,
821 ledger_text: &str,
822 ledger_flags: &str,
823 ) -> rusqlite::Result<()> {
824 let qname_exp = expand_identifier(&sym.qname);
825 let sig_orig = sym.signature.as_deref().unwrap_or("");
826 let sig_exp = if sig_orig.is_empty() {
827 String::new()
828 } else {
829 expand_text(sig_orig)
830 };
831 let doc = sym.doc.as_deref().unwrap_or("");
832 let file_exp = expand_text(&sym.file);
833 let kind = format!("{:?}", sym.kind).to_lowercase();
834 let tier = symbol_tier(&sym.file).to_string();
835
836 self.conn.execute(
837 "INSERT INTO asd_search_fts(
838 symbol_id, qname, signature, doc, file, language, kind, line,
839 qname_orig, sig_orig, file_orig, tier, ledger_text, ledger_flags)
840 VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
841 params![
842 sym.symbol_id,
843 qname_exp,
844 sig_exp,
845 doc,
846 file_exp,
847 sym.language,
848 kind,
849 sym.start.line,
850 sym.qname,
851 sym.signature.as_deref().unwrap_or(""),
852 sym.file,
853 tier,
854 ledger_text,
855 ledger_flags,
856 ],
857 )?;
858 Ok(())
859 }
860
861 pub fn search(
866 &self,
867 query: &str,
868 filters: &FtsFilters,
869 limit: usize,
870 ) -> rusqlite::Result<Vec<FtsHit>> {
871 if query.trim().is_empty() {
872 return Ok(vec![]);
873 }
874
875 let tokens: Vec<String> = query
876 .split(|c: char| c.is_whitespace() || c == '_' || c == '-' || c == '.')
877 .map(|t| t.to_lowercase())
878 .filter(|t| t.len() >= 2 && !is_stopword(t))
879 .collect();
880
881 if tokens.is_empty() {
882 return Ok(vec![]);
883 }
884
885 let match_expr = tokens
887 .iter()
888 .map(|t| format!("\"{}\"", t.replace('"', "")))
889 .collect::<Vec<_>>()
890 .join(" OR ");
891
892 let kind_clause = filters
894 .kind
895 .as_deref()
896 .map(|k| format!("AND kind = '{}'", k.to_lowercase().replace('\'', "")))
897 .unwrap_or_default();
898 let lang_clause = filters
899 .language
900 .as_deref()
901 .map(|l| format!("AND language = '{}'", l.to_lowercase().replace('\'', "")))
902 .unwrap_or_default();
903 let test_clause = tests_clause(filters.include_tests, filters.tests_only);
906
907 let fetch = (limit * 4).max(80);
909
910 let sql = format!(
914 "SELECT symbol_id, language, kind, line, doc,
915 qname_orig, sig_orig, file_orig, tier,
916 ledger_text, ledger_flags,
917 bm25(asd_search_fts, 10.0, 5.0, 5.0, 2.0) AS score
918 FROM asd_search_fts
919 WHERE asd_search_fts MATCH ?1
920 {test_clause}
921 {kind_clause}
922 {lang_clause}
923 ORDER BY score
924 LIMIT {fetch}"
925 );
926
927 let mut stmt = self.conn.prepare(&sql)?;
928 let hits = stmt
929 .query_map(params![match_expr], |row| {
930 let bm25_raw: f64 = row.get(11)?;
931 let sig_orig: Option<String> = row.get(6)?;
932 let tier_str: String = row.get(8).unwrap_or_default();
933 let tier: SymbolTier = tier_str.parse().unwrap_or(0);
934 Ok(FtsHit {
935 bm25_score: -bm25_raw,
936 symbol_id: row.get(0)?,
937 language: row.get(1)?,
938 kind: row.get(2)?,
939 line: row.get::<_, u32>(3).unwrap_or(0),
940 doc: row.get(4)?,
941 qname: row.get(5)?,
942 signature: sig_orig.filter(|s| !s.is_empty()),
943 file: row.get(7)?,
944 tier,
945 ledger_text: row.get::<_, String>(9).unwrap_or_default(),
946 ledger_flags: row.get::<_, String>(10).unwrap_or_default(),
947 })
948 })?
949 .filter_map(|r| r.ok())
950 .collect();
951
952 Ok(hits)
953 }
954
955 pub fn count_distinct_files_per_token(
959 &self,
960 tokens: &[&str],
961 include_tests: bool,
962 ) -> rusqlite::Result<Vec<(String, usize)>> {
963 if tokens.is_empty() {
964 return Ok(vec![]);
965 }
966 let test_clause = if include_tests { "" } else { "AND tier != '2'" };
967
968 let mut parts = Vec::with_capacity(tokens.len());
974 let mut params_vec: Vec<String> = Vec::with_capacity(tokens.len());
975 for (i, tok) in tokens.iter().enumerate() {
976 let tok_clean = tok.replace('"', "");
977 parts.push(format!(
978 "SELECT ?{} AS tok, COUNT(DISTINCT file_orig) AS cnt \
979 FROM asd_search_fts \
980 WHERE asd_search_fts MATCH '\"{}\"' {test_clause}",
981 i + 1,
982 tok_clean
983 ));
984 params_vec.push(tok_clean);
985 }
986 let sql = parts.join(" UNION ALL ");
987
988 let mut stmt = self.conn.prepare(&sql)?;
989 let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec
991 .iter()
992 .map(|s| s as &dyn rusqlite::types::ToSql)
993 .collect();
994 let results = stmt
995 .query_map(params_refs.as_slice(), |row| {
996 let tok: String = row.get(0)?;
997 let cnt: i64 = row.get(1)?;
998 Ok((tok, cnt as usize))
999 })?
1000 .filter_map(|r| r.ok())
1001 .collect();
1002 Ok(results)
1003 }
1004
1005 pub fn has_exact_symbol_name(&self, name: &str) -> bool {
1014 if name.len() < 3 {
1015 return false;
1016 }
1017 let name_lc = name.to_lowercase();
1018 let sql = "SELECT 1 FROM asd_search_fts \
1020 WHERE lower(qname_orig) = ?1 \
1021 OR lower(qname_orig) LIKE ?2 \
1022 LIMIT 1";
1023 let suffix_pattern = format!("%.{}", name_lc);
1024 self.conn
1025 .query_row(sql, rusqlite::params![name_lc, suffix_pattern], |_| {
1026 Ok(true)
1027 })
1028 .unwrap_or(false)
1029 }
1030
1031 pub fn has_data(&self) -> bool {
1033 self.conn
1034 .query_row("SELECT COUNT(*) FROM asd_search_fts LIMIT 1", [], |r| {
1035 r.get::<_, i64>(0)
1036 })
1037 .map(|n| n > 0)
1038 .unwrap_or(false)
1039 }
1040
1041 pub fn get_symbols_meta_bulk(&self, qnames: &[&str]) -> HashMap<String, SymbolMeta> {
1049 if qnames.is_empty() {
1050 return HashMap::new();
1051 }
1052 let placeholders: Vec<String> = (1..=qnames.len()).map(|i| format!("?{i}")).collect();
1055 let sql = format!(
1056 "SELECT qname, symbol_id, file, kind FROM asd_symbols_meta WHERE qname IN ({})",
1057 placeholders.join(", ")
1058 );
1059 let mut stmt = match self.conn.prepare(&sql) {
1060 Ok(s) => s,
1061 Err(_) => return HashMap::new(),
1062 };
1063 let params_refs: Vec<&dyn rusqlite::types::ToSql> = qnames
1064 .iter()
1065 .map(|q| q as &dyn rusqlite::types::ToSql)
1066 .collect();
1067 stmt.query_map(params_refs.as_slice(), |row| {
1068 Ok((
1069 row.get::<_, String>(0)?,
1070 SymbolMeta {
1071 symbol_id: row.get(1)?,
1072 file: row.get(2)?,
1073 kind: row.get(3)?,
1074 },
1075 ))
1076 })
1077 .map(|rows| rows.filter_map(|r| r.ok()).collect())
1078 .unwrap_or_default()
1079 }
1080
1081 pub fn anchor_candidates(
1094 &self,
1095 tokens: &[String],
1096 existing_qnames: &std::collections::HashSet<String>,
1097 limit: usize,
1098 ) -> Vec<(String, String)> {
1099 if tokens.is_empty() {
1100 return vec![];
1101 }
1102
1103 let flag_clause = "(ledger_flags LIKE '%invariant%' OR ledger_flags LIKE '%hazard%')";
1107
1108 let text_clauses: Vec<String> = tokens
1110 .iter()
1111 .map(|t| format!("ledger_text LIKE '%{}%'", t.replace('\'', "''")))
1112 .collect();
1113 let text_clause = format!("({})", text_clauses.join(" OR "));
1114
1115 let sql = format!(
1116 "SELECT qname_orig, symbol_id
1117 FROM asd_search_fts
1118 WHERE ledger_text != ''
1119 AND {flag_clause}
1120 AND {text_clause}
1121 AND tier != '2'
1122 LIMIT {limit}"
1123 );
1124
1125 let mut stmt = match self.conn.prepare(&sql) {
1126 Ok(s) => s,
1127 Err(_) => return vec![],
1128 };
1129
1130 stmt.query_map([], |row| {
1131 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1132 })
1133 .map(|rows| {
1134 rows.filter_map(|r| r.ok())
1135 .filter(|(qname, _)| !existing_qnames.contains(qname))
1136 .collect()
1137 })
1138 .unwrap_or_default()
1139 }
1140
1141 pub fn resolve_qnames_bulk(&self, qnames: &[&str]) -> HashMap<String, ResolvedSymbol> {
1155 if qnames.is_empty() {
1156 return HashMap::new();
1157 }
1158 let placeholders: Vec<String> = (1..=qnames.len()).map(|i| format!("?{i}")).collect();
1161 let sql = format!(
1162 "SELECT qname_orig, symbol_id, file_orig, kind, doc, sig_orig
1163 FROM asd_search_fts
1164 WHERE qname_orig IN ({})",
1165 placeholders.join(", ")
1166 );
1167 let mut stmt = match self.conn.prepare(&sql) {
1168 Ok(s) => s,
1169 Err(_) => return HashMap::new(),
1170 };
1171 let params_refs: Vec<&dyn rusqlite::types::ToSql> = qnames
1172 .iter()
1173 .map(|q| q as &dyn rusqlite::types::ToSql)
1174 .collect();
1175 stmt.query_map(params_refs.as_slice(), |row| {
1177 let qname: String = row.get(0)?;
1178 let doc_raw: Option<String> = row.get(4)?;
1179 let sig_raw: Option<String> = row.get(5)?;
1180 Ok((
1181 qname.clone(),
1182 ResolvedSymbol {
1183 symbol_id: row.get(1)?,
1184 qname,
1185 file: row.get(2)?,
1186 kind: row.get(3)?,
1187 doc: doc_raw.filter(|s| !s.is_empty()),
1188 signature: sig_raw.filter(|s| !s.is_empty()),
1189 },
1190 ))
1191 })
1192 .map(|rows| rows.filter_map(|r| r.ok()).collect())
1193 .unwrap_or_default()
1194 }
1195
1196 pub fn resolve_symbol_ids_bulk(&self, symbol_ids: &[&str]) -> HashMap<String, ResolvedSymbol> {
1201 if symbol_ids.is_empty() {
1202 return HashMap::new();
1203 }
1204 let placeholders: Vec<String> = (1..=symbol_ids.len()).map(|i| format!("?{i}")).collect();
1205 let sql = format!(
1207 "SELECT symbol_id, qname_orig, file_orig, kind, doc, sig_orig
1208 FROM asd_search_fts
1209 WHERE symbol_id IN ({})",
1210 placeholders.join(", ")
1211 );
1212 let mut stmt = match self.conn.prepare(&sql) {
1213 Ok(s) => s,
1214 Err(_) => return HashMap::new(),
1215 };
1216 let params_refs: Vec<&dyn rusqlite::types::ToSql> = symbol_ids
1217 .iter()
1218 .map(|s| s as &dyn rusqlite::types::ToSql)
1219 .collect();
1220 stmt.query_map(params_refs.as_slice(), |row| {
1221 let sym_id: String = row.get(0)?;
1222 let doc_raw: Option<String> = row.get(4)?;
1223 let sig_raw: Option<String> = row.get(5)?;
1224 Ok((
1225 sym_id.clone(),
1226 ResolvedSymbol {
1227 symbol_id: sym_id,
1228 qname: row.get(1)?,
1229 file: row.get(2)?,
1230 kind: row.get(3)?,
1231 doc: doc_raw.filter(|s| !s.is_empty()),
1232 signature: sig_raw.filter(|s| !s.is_empty()),
1233 },
1234 ))
1235 })
1236 .map(|rows| rows.filter_map(|r| r.ok()).collect())
1237 .unwrap_or_default()
1238 }
1239
1240 pub fn symbols_with_constraint_penalties(
1253 &self,
1254 ref_name: &str,
1255 ) -> rusqlite::Result<Vec<String>> {
1256 let pairs = self.symbols_with_constraint_penalties_scoped(ref_name)?;
1257 Ok(pairs.into_iter().map(|(sid, _)| sid).collect())
1258 }
1259
1260 pub fn symbols_with_constraint_penalties_scoped(
1272 &self,
1273 ref_name: &str,
1274 ) -> rusqlite::Result<Vec<(String, Option<Vec<String>>)>> {
1275 let mut stmt = self.conn.prepare(
1276 "SELECT symbol_id, body FROM asd_ledger_cache
1277 WHERE ref_name = ?1
1278 AND json_extract(body, '$.kind') IN ('decision', 'constraint')
1279 AND json_extract(body, '$.role') IN ('stale-api', 'audit-pending')",
1280 )?;
1281 let rows = stmt
1282 .query_map(params![ref_name], |r| {
1283 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1284 })?
1285 .filter_map(|r| r.ok())
1286 .map(|(sid, body)| {
1287 let scope = serde_json::from_str::<serde_json::Value>(&body)
1288 .ok()
1289 .and_then(|v| {
1290 v.get("body").and_then(|b| {
1291 b.as_str()
1293 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
1294 .or_else(|| Some(b.clone()))
1295 })
1296 })
1297 .and_then(|inner| inner.get("scope").and_then(|s| s.as_array().cloned()))
1298 .map(|arr| {
1299 arr.into_iter()
1300 .filter_map(|g| g.as_str().map(String::from))
1301 .filter(|g| !g.is_empty())
1302 .collect::<Vec<_>>()
1303 })
1304 .filter(|v| !v.is_empty());
1305 (sid, scope)
1306 })
1307 .collect::<Vec<_>>();
1308 Ok(rows)
1309 }
1310
1311 pub fn ledger_entry_count_for(&self, symbol_id: &str, ref_name: &str) -> usize {
1314 self.conn
1315 .query_row(
1316 "SELECT COUNT(*) FROM asd_ledger_cache WHERE symbol_id = ?1 AND ref_name = ?2",
1317 params![symbol_id, ref_name],
1318 |r| r.get::<_, i64>(0),
1319 )
1320 .unwrap_or(0) as usize
1321 }
1322
1323 pub fn upsert_ledger_entry(&self, entry: &LedgerEntry, ref_name: &str) -> rusqlite::Result<()> {
1335 let body = serde_json::to_string(entry).unwrap_or_else(|_| "{}".to_string());
1336 self.conn.execute(
1337 "INSERT OR REPLACE INTO asd_ledger_cache (entry_id, symbol_id, ref_name, body)
1338 VALUES (?1, ?2, ?3, ?4)",
1339 params![entry.entry_id, entry.symbol_id, ref_name, body],
1340 )?;
1341
1342 let summary_lower = entry.summary.to_lowercase();
1346 let kind_str = entry.kind.as_str();
1347 let _ = self.conn.execute(
1350 "UPDATE asd_search_fts
1351 SET ledger_text = CASE
1352 WHEN ledger_text IS NULL OR ledger_text = '' THEN ?1
1353 ELSE ledger_text || ' ' || ?1
1354 END,
1355 ledger_flags = CASE
1356 WHEN ledger_flags IS NULL OR ledger_flags = '' THEN ?2
1357 WHEN INSTR(',' || ledger_flags || ',', ',' || ?2 || ',') > 0 THEN ledger_flags
1358 ELSE ledger_flags || ',' || ?2
1359 END
1360 WHERE symbol_id = ?3",
1361 params![summary_lower, kind_str, entry.symbol_id],
1362 );
1363 Ok(())
1364 }
1365
1366 pub fn list_ledger_entries_for(
1369 &self,
1370 symbol_id: &str,
1371 ref_name: &str,
1372 ) -> rusqlite::Result<Vec<LedgerEntry>> {
1373 let mut stmt = self.conn.prepare(
1374 "SELECT body FROM asd_ledger_cache
1375 WHERE symbol_id = ?1 AND ref_name = ?2
1376 ORDER BY rowid DESC",
1377 )?;
1378 let entries = stmt
1379 .query_map(params![symbol_id, ref_name], |row| {
1380 let body: String = row.get(0)?;
1381 Ok(body)
1382 })?
1383 .filter_map(|r| r.ok())
1384 .filter_map(|body| serde_json::from_str::<LedgerEntry>(&body).ok())
1385 .collect();
1386 Ok(entries)
1387 }
1388
1389 pub fn ledger_cache_entry_ids(&self, ref_name: &str) -> rusqlite::Result<Vec<String>> {
1392 let mut stmt = self
1393 .conn
1394 .prepare("SELECT entry_id FROM asd_ledger_cache WHERE ref_name = ?1")?;
1395 let ids = stmt
1396 .query_map(params![ref_name], |row| row.get::<_, String>(0))?
1397 .collect::<rusqlite::Result<Vec<_>>>()?;
1398 Ok(ids)
1399 }
1400
1401 pub fn all_ledger_entries(
1405 &self,
1406 ref_name: &str,
1407 ) -> rusqlite::Result<(Vec<LedgerEntry>, Vec<String>)> {
1408 let mut stmt = self
1409 .conn
1410 .prepare("SELECT entry_id, body FROM asd_ledger_cache WHERE ref_name = ?1")?;
1411 let rows = stmt.query_map(params![ref_name], |row| {
1412 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1413 })?;
1414 let mut entries = Vec::new();
1415 let mut unparseable = Vec::new();
1416 for row in rows {
1417 let (entry_id, body) = row?;
1418 match serde_json::from_str::<LedgerEntry>(&body) {
1419 Ok(e) => entries.push(e),
1420 Err(_) => unparseable.push(entry_id),
1421 }
1422 }
1423 Ok((entries, unparseable))
1424 }
1425
1426 pub fn sync_ledger_entries(
1430 &self,
1431 entries: &[(String, LedgerEntry)],
1432 ref_name: &str,
1433 ) -> rusqlite::Result<()> {
1434 self.conn.execute_batch("BEGIN;")?;
1435 for (symbol_id, entry) in entries {
1436 let body = serde_json::to_string(entry).unwrap_or_else(|_| "{}".to_string());
1437 if let Err(err) = self.conn.execute(
1438 "INSERT OR REPLACE INTO asd_ledger_cache (entry_id, symbol_id, ref_name, body)
1439 VALUES (?1, ?2, ?3, ?4)",
1440 params![entry.entry_id, symbol_id, ref_name, body],
1441 ) {
1442 eprintln!("asd: ledger cache sync warning — {err}");
1443 }
1444 }
1445 self.conn.execute_batch("COMMIT;")?;
1446 Ok(())
1447 }
1448
1449 pub fn effects_cached_for(&self, symbol_id: &str, ref_name: &str) -> bool {
1455 self.conn
1456 .query_row(
1457 "SELECT 1 FROM asd_effects_cache WHERE symbol_id = ?1 AND ref_name = ?2 LIMIT 1",
1458 params![symbol_id, ref_name],
1459 |_| Ok(()),
1460 )
1461 .is_ok()
1462 }
1463
1464 pub fn upsert_effects(
1466 &self,
1467 symbol_id: &str,
1468 ref_name: &str,
1469 decl: &EffectDecl,
1470 ) -> rusqlite::Result<()> {
1471 let body = serde_json::to_string(decl).unwrap_or_else(|_| "{}".to_string());
1472 self.conn.execute(
1473 "INSERT OR REPLACE INTO asd_effects_cache (symbol_id, ref_name, body)
1474 VALUES (?1, ?2, ?3)",
1475 params![symbol_id, ref_name, body],
1476 )?;
1477 Ok(())
1478 }
1479
1480 pub fn delete_effects_for(
1482 &self,
1483 symbol_ids: &[String],
1484 ref_name: &str,
1485 ) -> rusqlite::Result<()> {
1486 if symbol_ids.is_empty() {
1487 return Ok(());
1488 }
1489 self.conn.execute_batch("BEGIN;")?;
1490 for symbol_id in symbol_ids {
1491 if let Err(e) = self.conn.execute(
1492 "DELETE FROM asd_effects_cache WHERE symbol_id = ?1 AND ref_name = ?2",
1493 params![symbol_id, ref_name],
1494 ) {
1495 let _ = self.conn.execute_batch("ROLLBACK;");
1496 return Err(e);
1497 }
1498 }
1499 self.conn.execute_batch("COMMIT;")
1500 }
1501
1502 pub fn get_effects_for(
1504 &self,
1505 symbol_id: &str,
1506 ref_name: &str,
1507 ) -> rusqlite::Result<Option<EffectDecl>> {
1508 match self.conn.query_row(
1509 "SELECT body FROM asd_effects_cache WHERE symbol_id = ?1 AND ref_name = ?2 LIMIT 1",
1510 params![symbol_id, ref_name],
1511 |row| row.get::<_, String>(0),
1512 ) {
1513 Ok(body) => Ok(serde_json::from_str::<EffectDecl>(&body).ok()),
1514 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1515 Err(e) => Err(e),
1516 }
1517 }
1518
1519 pub fn sync_effects(
1522 &self,
1523 entries: &[(String, EffectDecl)],
1524 ref_name: &str,
1525 ) -> rusqlite::Result<()> {
1526 self.conn.execute_batch("BEGIN;")?;
1527 for (symbol_id, decl) in entries {
1528 let body = serde_json::to_string(decl).unwrap_or_else(|_| "{}".to_string());
1529 if let Err(err) = self.conn.execute(
1530 "INSERT OR REPLACE INTO asd_effects_cache (symbol_id, ref_name, body)
1531 VALUES (?1, ?2, ?3)",
1532 params![symbol_id, ref_name, body],
1533 ) {
1534 eprintln!("asd: effects cache sync warning — {err}");
1535 }
1536 }
1537 self.conn.execute_batch("COMMIT;")?;
1538 Ok(())
1539 }
1540
1541 pub fn feedback_count(&self) -> usize {
1548 self.conn
1549 .query_row("SELECT COUNT(*) FROM asd_feedback", [], |r| {
1550 r.get::<_, i64>(0)
1551 })
1552 .unwrap_or(0) as usize
1553 }
1554
1555 pub fn upsert_feedback(&self, e: &FeedbackEntry) -> rusqlite::Result<()> {
1560 self.conn.execute(
1561 "INSERT OR REPLACE INTO asd_feedback
1562 (entry_id, symbol_id, symbol_qname, query, verdict, author, created_at, note, file_scope, expires_at,
1563 withdrawn_at, withdrawn_by, withdrawn_reason)
1564 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
1565 rusqlite::params![
1566 e.entry_id,
1567 e.symbol_id,
1568 e.symbol_qname,
1569 e.query,
1570 e.verdict.as_str(),
1571 e.author,
1572 e.created_at.to_rfc3339(),
1573 e.note,
1574 e.file_scope,
1575 e.expires_at.map(|t| t.to_rfc3339()),
1578 e.withdrawn_at.map(|t| t.to_rfc3339()),
1583 e.withdrawn_by,
1584 e.withdrawn_reason,
1585 ],
1586 )?;
1587 Ok(())
1588 }
1589
1590 pub fn delete_feedback(&self, entry_id: &str) -> rusqlite::Result<usize> {
1598 self.conn.execute(
1599 "DELETE FROM asd_feedback WHERE entry_id = ?1",
1600 rusqlite::params![entry_id],
1601 )
1602 }
1603
1604 pub fn list_all_feedback(&self) -> rusqlite::Result<Vec<FeedbackEntry>> {
1605 let mut stmt = self.conn.prepare(
1606 "SELECT entry_id, symbol_id, symbol_qname, query, verdict, author, created_at, note, file_scope, expires_at,
1607 withdrawn_at, withdrawn_by, withdrawn_reason
1608 FROM asd_feedback
1609 ORDER BY created_at DESC",
1610 )?;
1611 let entries = stmt
1612 .query_map([], |row| {
1613 let verdict_str: String = row.get(4)?;
1614 let verdict =
1615 FeedbackVerdict::from_str(&verdict_str).unwrap_or(FeedbackVerdict::Useful);
1616 let ts_str: String = row.get(6)?;
1617 let created_at: DateTime<Utc> = DateTime::parse_from_rfc3339(&ts_str)
1618 .map(|d| d.with_timezone(&Utc))
1619 .unwrap_or_else(|_| Utc::now());
1620 let expires_at: Option<DateTime<Utc>> = row
1624 .get::<_, Option<String>>(9)?
1625 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1626 .map(|d| d.with_timezone(&Utc));
1627 let withdrawn_at: Option<DateTime<Utc>> = row
1628 .get::<_, Option<String>>(10)?
1629 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1630 .map(|d| d.with_timezone(&Utc));
1631 Ok(FeedbackEntry {
1632 entry_id: row.get(0)?,
1633 symbol_id: row.get(1)?,
1634 symbol_qname: row.get(2)?,
1635 query: row.get(3)?,
1636 verdict,
1637 author: row.get(5)?,
1638 created_at,
1639 note: row.get(7)?,
1640 file_scope: row.get(8)?,
1641 expires_at,
1642 withdrawn_at,
1643 withdrawn_by: row.get(11)?,
1644 withdrawn_reason: row.get(12)?,
1645 })
1646 })?
1647 .filter_map(|r| r.ok())
1648 .collect();
1649 Ok(entries)
1650 }
1651
1652 pub fn sync_feedback_entries(&self, entries: &[FeedbackEntry]) -> rusqlite::Result<()> {
1656 self.conn.execute_batch("BEGIN;")?;
1657 for e in entries {
1658 if let Err(err) = self.upsert_feedback(e) {
1659 eprintln!("asd: feedback sync warning — {err}");
1661 }
1662 }
1663 self.conn.execute_batch("COMMIT;")?;
1664 Ok(())
1665 }
1666
1667 pub fn file_stem_candidates(
1674 &self,
1675 token: &str,
1676 filters: &FtsFilters,
1677 limit: usize,
1678 ) -> rusqlite::Result<Vec<FtsHit>> {
1679 if token.len() < 2 {
1680 return Ok(vec![]);
1681 }
1682 let test_clause = tests_clause(filters.include_tests, filters.tests_only);
1683 let kind_clause = filters
1684 .kind
1685 .as_deref()
1686 .map(|k| format!("AND kind = '{}'", k.to_lowercase().replace('\'', "")))
1687 .unwrap_or_default();
1688 let sql = format!(
1693 "SELECT symbol_id, language, kind, MIN(line) as line, doc,
1694 qname_orig, sig_orig, file_orig, tier,
1695 ledger_text, ledger_flags
1696 FROM asd_search_fts
1697 WHERE lower(file_orig) LIKE '%' || lower(?1) || '%'
1698 {test_clause}
1699 {kind_clause}
1700 GROUP BY file_orig
1701 LIMIT {limit}"
1702 );
1703 let token_owned = token.to_lowercase();
1704 let mut stmt = self.conn.prepare(&sql)?;
1705 let hits = stmt
1706 .query_map(params![token_owned], |row| {
1707 let sig_orig: Option<String> = row.get(6)?;
1708 let tier_str: String = row.get(8).unwrap_or_default();
1709 let tier: SymbolTier = tier_str.parse().unwrap_or(0);
1710 Ok(FtsHit {
1711 bm25_score: 0.0,
1712 symbol_id: row.get(0)?,
1713 language: row.get(1)?,
1714 kind: row.get(2)?,
1715 line: row.get::<_, u32>(3).unwrap_or(0),
1716 doc: row.get(4)?,
1717 qname: row.get(5)?,
1718 signature: sig_orig.filter(|s| !s.is_empty()),
1719 file: row.get(7)?,
1720 tier,
1721 ledger_text: row.get::<_, String>(9).unwrap_or_default(),
1722 ledger_flags: row.get::<_, String>(10).unwrap_or_default(),
1723 })
1724 })?
1725 .filter_map(|r| r.ok())
1726 .collect();
1727 Ok(hits)
1728 }
1729
1730 pub fn file_stem_candidates_batch(
1739 &self,
1740 tokens: &[String],
1741 filters: &FtsFilters,
1742 limit: usize,
1743 ) -> rusqlite::Result<Vec<FtsHit>> {
1744 let valid: Vec<String> = tokens
1746 .iter()
1747 .filter(|t| t.len() >= 2)
1748 .map(|t| t.to_lowercase())
1749 .collect();
1750 if valid.is_empty() {
1751 return Ok(vec![]);
1752 }
1753
1754 let test_clause = tests_clause(filters.include_tests, filters.tests_only);
1755 let kind_clause = filters
1756 .kind
1757 .as_deref()
1758 .map(|k| format!("AND kind = '{}'", k.to_lowercase().replace('\'', "")))
1759 .unwrap_or_default();
1760
1761 let arm_sql = |n: usize| -> String {
1763 format!(
1764 "SELECT symbol_id, language, kind, MIN(line) AS line, doc,
1765 qname_orig, sig_orig, file_orig, tier,
1766 ledger_text, ledger_flags
1767 FROM asd_search_fts
1768 WHERE lower(file_orig) LIKE '%' || ?{n} || '%'
1769 {test_clause}
1770 {kind_clause}
1771 GROUP BY file_orig"
1772 )
1773 };
1774
1775 let sql = valid
1776 .iter()
1777 .enumerate()
1778 .map(|(i, _)| arm_sql(i + 1))
1779 .collect::<Vec<_>>()
1780 .join("\nUNION ALL\n");
1781
1782 let mut stmt = self.conn.prepare(&sql)?;
1783 let mut seen_files: std::collections::HashSet<String> = std::collections::HashSet::new();
1784 let hits: Vec<FtsHit> = stmt
1785 .query_map(rusqlite::params_from_iter(valid.iter()), |row| {
1786 let sig_orig: Option<String> = row.get(6)?;
1787 let tier_str: String = row.get(8).unwrap_or_default();
1788 let tier: SymbolTier = tier_str.parse().unwrap_or(0);
1789 Ok(FtsHit {
1790 bm25_score: 0.0,
1791 symbol_id: row.get(0)?,
1792 language: row.get(1)?,
1793 kind: row.get(2)?,
1794 line: row.get::<_, u32>(3).unwrap_or(0),
1795 doc: row.get(4)?,
1796 qname: row.get(5)?,
1797 signature: sig_orig.filter(|s| !s.is_empty()),
1798 file: row.get(7)?,
1799 tier,
1800 ledger_text: row.get::<_, String>(9).unwrap_or_default(),
1801 ledger_flags: row.get::<_, String>(10).unwrap_or_default(),
1802 })
1803 })?
1804 .filter_map(|r| r.ok())
1805 .filter(|h| seen_files.insert(h.file.clone()))
1806 .take(limit)
1807 .collect();
1808 Ok(hits)
1809 }
1810}
1811
1812pub fn hybrid_boost(hit: &FtsHit, tokens: &[String]) -> f64 {
1832 if tokens.is_empty() {
1833 return 0.0;
1834 }
1835
1836 let path_words: Vec<String> = hit
1838 .file
1839 .split(|c: char| c == '/' || c == '\\' || c == '.')
1840 .filter(|s| !s.is_empty())
1841 .flat_map(|seg| {
1842 let mut words: Vec<String> = vec![seg.to_lowercase()];
1843 words.extend(split_camel(seg).iter().map(|w| w.to_lowercase()));
1844 words
1845 })
1846 .filter(|w| w.len() >= 2)
1847 .collect();
1848
1849 let path_boost = tokens
1850 .iter()
1851 .filter(|t| path_words.iter().any(|w| w == t.as_str()))
1852 .count() as f64
1853 * 2.5;
1854
1855 let last_seg = hit.qname.rsplit('.').next().unwrap_or(&hit.qname);
1857 let name_words: Vec<String> = {
1858 let mut words: Vec<String> = vec![last_seg.to_lowercase()];
1859 words.extend(split_camel(last_seg).iter().map(|w| w.to_lowercase()));
1860 words
1861 };
1862
1863 let name_boost = tokens
1864 .iter()
1865 .filter(|t| name_words.iter().any(|w| w == t.as_str()))
1866 .count() as f64
1867 * 2.0;
1868
1869 let phrase_bonus = if tokens.len() >= 2 {
1874 let mut bonus = 0.0f64;
1875 let lower_tokens: Vec<&str> = tokens.iter().map(|t| t.as_str()).collect();
1876 for window_start in 0..name_words.len().saturating_sub(1) {
1877 for pair_len in 2..=(lower_tokens.len().min(name_words.len() - window_start)) {
1878 let name_slice = &name_words[window_start..window_start + pair_len];
1879 for tok_start in 0..=lower_tokens.len().saturating_sub(pair_len) {
1880 let tok_slice = &lower_tokens[tok_start..tok_start + pair_len];
1881 if name_slice
1882 .iter()
1883 .map(|s| s.as_str())
1884 .eq(tok_slice.iter().copied())
1885 {
1886 bonus = bonus.max(pair_len as f64 * 3.0);
1887 }
1888 }
1889 }
1890 }
1891 bonus
1892 } else {
1893 0.0
1894 };
1895
1896 let tier_penalty = if hit.tier == 1 { -2.0 } else { 0.0 };
1898
1899 const GENERIC_TOKENS: &[&str] = &[
1906 "state",
1907 "update",
1908 "local",
1909 "position",
1910 "value",
1911 "data",
1912 "item",
1913 "list",
1914 "info",
1915 "event",
1916 "action",
1917 "type",
1918 "mode",
1919 "flag",
1920 "current",
1921 "node",
1922 "result",
1923 "status",
1924 "record",
1925 "entry",
1926 "object",
1927 "element",
1928 "get",
1929 "set",
1930 "add",
1931 "remove",
1932 "reset",
1933 "apply",
1934 "build",
1935 "make",
1936 "playhead",
1937 "cursor",
1938 "progress",
1939 "indicator",
1940 "tick",
1941 ];
1942 let matched_tokens: Vec<&str> = tokens
1943 .iter()
1944 .filter(|t| {
1945 name_words.iter().any(|w| w == t.as_str()) || path_words.iter().any(|w| w == t.as_str())
1946 })
1947 .map(|t| t.as_str())
1948 .collect();
1949 let generic_penalty = if !matched_tokens.is_empty()
1950 && matched_tokens.iter().all(|t| GENERIC_TOKENS.contains(t))
1951 {
1952 -2.5 } else {
1954 0.0
1955 };
1956
1957 path_boost + name_boost + phrase_bonus + tier_penalty + generic_penalty
1958}
1959
1960pub fn format_age(indexed_at: i64) -> String {
1966 let now = std::time::SystemTime::now()
1967 .duration_since(std::time::UNIX_EPOCH)
1968 .map(|d| d.as_secs() as i64)
1969 .unwrap_or(0);
1970 let secs = (now - indexed_at).max(0);
1971 if secs < 60 {
1972 "just now".to_string()
1973 } else if secs < 3600 {
1974 format!("{}m ago", secs / 60)
1975 } else if secs < 86400 {
1976 format!("{}h ago", secs / 3600)
1977 } else {
1978 format!("{}d ago", secs / 86400)
1979 }
1980}
1981
1982pub fn stale_warning(db_path: &std::path::Path, threshold_secs: u64) -> Option<String> {
1992 let fts = SearchFtsDb::open(db_path).ok()?;
1993 if !fts.has_data() {
1994 return Some("asd: index is empty — run 'asd index <dir>' to build it.".to_string());
1995 }
1996
1997 if fts.fts_last_rebuild_ok() == Some(false) {
2000 return Some(
2001 "asd: symbols are indexed but the FTS search index failed to rebuild \
2002 during the last 'asd index' run (database may have been locked). \
2003 Search ranking may be stale — re-run 'asd index <dir>' to repair."
2004 .to_string(),
2005 );
2006 }
2007
2008 let indexed_at = fts.last_indexed_at()?;
2009 let now = std::time::SystemTime::now()
2010 .duration_since(std::time::UNIX_EPOCH)
2011 .map(|d| d.as_secs() as i64)
2012 .unwrap_or(0);
2013 let age_secs = (now - indexed_at).max(0) as u64;
2014 if age_secs > threshold_secs {
2015 Some(format!(
2016 "asd: index may be stale — last indexed {}. Run 'asd index <dir>' to update.",
2017 format_age(indexed_at)
2018 ))
2019 } else {
2020 None
2021 }
2022}
2023
2024#[derive(Debug, Clone, serde::Serialize)]
2031pub struct StaleWarning {
2032 pub message: String,
2033 pub severity: StaleSeverity,
2034 pub age_secs: u64,
2035 pub indexed_at: Option<i64>,
2036}
2037
2038#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
2039#[serde(rename_all = "snake_case")]
2040pub enum StaleSeverity {
2041 Critical,
2044 Soft,
2049}
2050
2051pub fn stale_warning_classified(
2052 db_path: &std::path::Path,
2053 threshold_secs: u64,
2054) -> Option<StaleWarning> {
2055 let fts = SearchFtsDb::open(db_path).ok()?;
2056 if !fts.has_data() {
2057 return Some(StaleWarning {
2058 message: "asd: index is empty — run 'asd index <dir>' to build it.".to_string(),
2059 severity: StaleSeverity::Critical,
2060 age_secs: 0,
2061 indexed_at: None,
2062 });
2063 }
2064 if fts.fts_last_rebuild_ok() == Some(false) {
2065 return Some(StaleWarning {
2066 message: "asd: symbols are indexed but the FTS search index failed to rebuild \
2067 during the last 'asd index' run (database may have been locked). \
2068 Search ranking may be stale — re-run 'asd index <dir>' to repair."
2069 .to_string(),
2070 severity: StaleSeverity::Critical,
2071 age_secs: 0,
2072 indexed_at: None,
2073 });
2074 }
2075 let indexed_at = fts.last_indexed_at()?;
2076 let now = std::time::SystemTime::now()
2077 .duration_since(std::time::UNIX_EPOCH)
2078 .map(|d| d.as_secs() as i64)
2079 .unwrap_or(0);
2080 let age_secs = (now - indexed_at).max(0) as u64;
2081 if age_secs > threshold_secs {
2082 Some(StaleWarning {
2083 message: format!(
2084 "asd: index may be stale — last indexed {}. Run 'asd index <dir>' to update.",
2085 format_age(indexed_at)
2086 ),
2087 severity: StaleSeverity::Soft,
2088 age_secs,
2089 indexed_at: Some(indexed_at),
2090 })
2091 } else {
2092 None
2093 }
2094}
2095
2096pub const SOFT_STALE_THRESHOLD_SECS: u64 = 86_400;
2103
2104pub fn compute_index_consistency(asg_symbols: usize, fts_symbols: usize) -> serde_json::Value {
2129 let delta = asg_symbols as i64 - fts_symbols as i64;
2130 if delta == 0 {
2131 return serde_json::Value::Null;
2137 }
2138 let advice = if delta > 0 {
2139 format!(
2140 "ASG has {delta} symbol{p} not in the FTS search cache — run 'asd index' to rebuild the search index.",
2141 p = if delta == 1 { "" } else { "s" }
2142 )
2143 } else {
2144 let extra = -delta;
2148 format!(
2149 "FTS holds {extra} stale symbol{p} no longer in the ASG — run 'asd index' to rebuild.",
2150 p = if extra == 1 { "" } else { "s" }
2151 )
2152 };
2153 serde_json::json!({
2154 "asg_symbols": asg_symbols,
2155 "fts_symbols": fts_symbols,
2156 "delta": delta,
2157 "consistent": false,
2158 "advice": advice,
2159 })
2160}
2161
2162pub const AGENT_DEFAULT_BUDGET: usize = 8_000;
2168
2169pub fn trim_for_agent(v: &serde_json::Value, max_list: usize) -> serde_json::Value {
2180 use serde_json::Value;
2181
2182 const DROP_FIELDS: &[&str] = &["body", "doc", "tokens"];
2184 const TRUNCATE_ARRAYS: &[&str] = &[
2186 "callers",
2187 "callees",
2188 "notes",
2189 "decisions_and_notes",
2190 "proofs",
2191 "ownership",
2192 "other_ledger",
2193 ];
2194 const SIMPLIFY_REFS: &[&str] = &["callers", "callees"];
2196
2197 match v {
2198 Value::Object(map) => {
2199 let mut out = serde_json::Map::new();
2200 for (k, val) in map {
2201 if DROP_FIELDS.contains(&k.as_str()) {
2202 continue;
2203 }
2204 let trimmed = trim_for_agent(val, max_list);
2205 let final_val = if TRUNCATE_ARRAYS.contains(&k.as_str()) {
2206 if let Value::Array(arr) = &trimmed {
2207 let slice: Vec<Value> = arr
2208 .iter()
2209 .take(max_list)
2210 .map(|item| {
2211 if SIMPLIFY_REFS.contains(&k.as_str()) {
2212 if let Value::Object(obj) = item {
2214 let mut mini = serde_json::Map::new();
2215 if let Some(q) = obj.get("qname") {
2216 mini.insert("qname".into(), q.clone());
2217 }
2218 if let Some(f) = obj.get("file") {
2219 mini.insert("file".into(), f.clone());
2220 }
2221 Value::Object(mini)
2222 } else {
2223 item.clone()
2224 }
2225 } else {
2226 item.clone()
2227 }
2228 })
2229 .collect();
2230 let truncated = arr.len() > max_list;
2231 if truncated {
2232 Value::Array({
2233 let mut s = slice;
2234 s.push(serde_json::json!(format!(
2235 "... {} more",
2236 arr.len() - max_list
2237 )));
2238 s
2239 })
2240 } else {
2241 Value::Array(slice)
2242 }
2243 } else {
2244 trimmed
2245 }
2246 } else if k == "recently_touched" {
2247 if let Value::Array(files) = &trimmed {
2249 let capped: Vec<Value> = files
2250 .iter()
2251 .take(3)
2252 .map(|file_entry| {
2253 if let Value::Object(obj) = file_entry {
2254 let mut m = obj.clone();
2255 if let Some(Value::Array(commits)) = m.get_mut("commits") {
2256 commits.truncate(max_list);
2257 }
2258 Value::Object(m)
2259 } else {
2260 file_entry.clone()
2261 }
2262 })
2263 .collect();
2264 Value::Array(capped)
2265 } else {
2266 trimmed
2267 }
2268 } else {
2269 trimmed
2270 };
2271 out.insert(k.clone(), final_val);
2272 }
2273 Value::Object(out)
2274 }
2275 Value::Array(arr) => {
2276 Value::Array(arr.iter().map(|i| trim_for_agent(i, max_list)).collect())
2277 }
2278 other => other.clone(),
2279 }
2280}
2281
2282pub fn estimate_tokens(json: &str) -> usize {
2284 json.len().saturating_add(3) / 4
2285}
2286
2287pub fn parse_intent(s: &str) -> Option<&'static str> {
2296 match s.to_lowercase().as_str() {
2297 "bugfix" => Some("bugfix"),
2298 "feature" => Some("feature"),
2299 "refactor" => Some("refactor"),
2300 "test" => Some("test"),
2301 "architecture" => Some("architecture"),
2302 "ui" => Some("ui"),
2303 _ => None,
2304 }
2305}
2306
2307pub fn intent_focus(intent: &str) -> &'static str {
2309 match intent {
2310 "bugfix" => {
2311 "Focus: callers that may be broken, effects, invariants to preserve, affected tests."
2312 }
2313 "feature" => "Focus: callees to extend, ownership boundaries, empty extension points.",
2314 "refactor" => {
2315 "Focus: callers (blast radius), invariants that must hold, ownership constraints."
2316 }
2317 "test" => {
2318 "Focus: affected test symbols, effects under test, existing proof ledger entries."
2319 }
2320 "architecture" => {
2321 "Focus: invariants, ownership boundaries, layer grouping, cross-layer effects."
2322 }
2323 "ui" => "Focus: UI/ViewModel layers, effects that touch display state, scheduler coupling.",
2324 _ => "",
2325 }
2326}
2327
2328pub fn intent_layer_order(intent: &str) -> &'static [&'static str] {
2331 match intent {
2332 "ui" => &[
2333 "ui",
2334 "viewmodel",
2335 "scheduler",
2336 "core_model",
2337 "persistence",
2338 "utility",
2339 "tests",
2340 "other",
2341 ],
2342 "architecture" => &[
2343 "core_model",
2344 "persistence",
2345 "scheduler",
2346 "viewmodel",
2347 "ui",
2348 "utility",
2349 "tests",
2350 "other",
2351 ],
2352 "bugfix" => &[
2353 "core_model",
2354 "scheduler",
2355 "persistence",
2356 "viewmodel",
2357 "ui",
2358 "tests",
2359 "utility",
2360 "other",
2361 ],
2362 "test" => &[
2363 "tests",
2364 "core_model",
2365 "scheduler",
2366 "persistence",
2367 "viewmodel",
2368 "ui",
2369 "utility",
2370 "other",
2371 ],
2372 _ => &[
2373 "ui",
2374 "viewmodel",
2375 "scheduler",
2376 "core_model",
2377 "persistence",
2378 "utility",
2379 "tests",
2380 "other",
2381 ],
2382 }
2383}
2384
2385#[derive(Debug, Clone)]
2391pub struct FileRecency {
2392 pub last_touched_days: Option<f64>,
2395 pub hot: bool,
2397}
2398
2399pub fn git_dirty_files() -> std::collections::HashSet<String> {
2404 use std::process::Command as Proc;
2405 let out = Proc::new("git")
2406 .args(["status", "--short", "--untracked-files=no"])
2407 .output();
2408 let Ok(o) = out else {
2409 return std::collections::HashSet::new();
2410 };
2411 if !o.status.success() {
2412 return std::collections::HashSet::new();
2413 }
2414 const SRC_EXTS: &[&str] = &[
2415 ".swift", ".py", ".ts", ".tsx", ".js", ".rs", ".go", ".kt", ".java", ".rb", ".cs", ".m",
2416 ".mm", ".cpp", ".c",
2417 ];
2418 String::from_utf8_lossy(&o.stdout)
2419 .lines()
2420 .filter(|l| l.len() > 3 && SRC_EXTS.iter().any(|ext| l.ends_with(ext)))
2422 .map(|l| l[3..].trim().to_string())
2423 .collect()
2424}
2425
2426pub fn derive_cold_hints(qname: &str, signature: Option<&str>, doc: Option<&str>) -> Vec<String> {
2437 let mut hints: Vec<String> = Vec::new();
2438
2439 if let Some(d) = doc {
2441 let first = d.lines().next().unwrap_or("").trim();
2442 let first = first.split('.').next().unwrap_or("").trim();
2443 if first.len() > 12 {
2445 hints.push(format!("verify: {}", first.to_lowercase()));
2446 }
2447 }
2448
2449 let leaf = qname
2451 .split(|c: char| c == '.' || c == ':' || c == '/')
2452 .last()
2453 .unwrap_or(qname);
2454 let words = split_identifier_words(leaf);
2455 if words.len() >= 2 {
2456 hints.push(format!(
2457 "verify {} behavior",
2458 words.join(" ").to_lowercase()
2459 ));
2460 } else if words.len() == 1 && hints.is_empty() {
2461 hints.push(format!("verify {} is correct", words[0].to_lowercase()));
2462 }
2463
2464 if let Some(sig) = signature {
2466 let params = extract_sig_param_names(sig);
2467 if !params.is_empty() && params.len() <= 4 {
2468 hints.push(format!("verify effect of {} on output", params.join(", ")));
2469 }
2470 }
2471
2472 hints.truncate(3);
2473 hints
2474}
2475
2476fn split_identifier_words(s: &str) -> Vec<String> {
2478 let mut words: Vec<String> = Vec::new();
2479 let mut cur = String::new();
2480 for ch in s.chars() {
2481 if ch == '_' || ch == '-' {
2482 if !cur.is_empty() {
2483 words.push(cur.clone());
2484 cur.clear();
2485 }
2486 } else if ch.is_uppercase() && !cur.is_empty() {
2487 words.push(cur.clone());
2488 cur.clear();
2489 cur.push(ch);
2490 } else {
2491 cur.push(ch);
2492 }
2493 }
2494 if !cur.is_empty() {
2495 words.push(cur);
2496 }
2497 words.into_iter().filter(|w| w.len() > 1).collect()
2498}
2499
2500fn extract_sig_param_names(sig: &str) -> Vec<String> {
2503 let inner = match (sig.find('('), sig.rfind(')')) {
2505 (Some(a), Some(b)) if b > a + 1 => &sig[a + 1..b],
2506 _ => return vec![],
2507 };
2508 let mut names: Vec<String> = Vec::new();
2509 for part in inner.split(',') {
2510 let part = part.trim();
2511 if part.is_empty() {
2512 continue;
2513 }
2514 let token = part
2516 .split(|c: char| c.is_whitespace() || c == ':')
2517 .find(|t| !t.is_empty())
2518 .unwrap_or("")
2519 .trim_start_matches(['_', '&'])
2520 .trim_start_matches("mut ")
2521 .to_string();
2522 if token.len() > 1 && token.chars().all(|c| c.is_alphanumeric() || c == '_') {
2523 names.push(token.to_lowercase());
2524 }
2525 }
2526 names
2527}
2528
2529pub fn find_indexed_test_files(db_path: &std::path::Path, source_file: &str) -> Vec<String> {
2535 let stem = std::path::Path::new(source_file)
2536 .file_stem()
2537 .and_then(|s| s.to_str())
2538 .unwrap_or("")
2539 .to_lowercase();
2540 if stem.is_empty() {
2541 return vec![];
2542 }
2543 let Ok(db) = SearchFtsDb::open(db_path) else {
2544 return vec![];
2545 };
2546 let Ok(mut stmt) = db
2548 .conn
2549 .prepare("SELECT DISTINCT file_orig FROM asd_search_fts WHERE tier = 2")
2550 else {
2551 return vec![];
2552 };
2553 let rows: Vec<String> = stmt
2554 .query_map([], |r| r.get::<_, String>(0))
2555 .map(|iter| iter.filter_map(|r| r.ok()).collect())
2556 .unwrap_or_default();
2557 rows.into_iter()
2559 .filter(|f| {
2560 let f_stem = std::path::Path::new(f)
2561 .file_stem()
2562 .and_then(|s| s.to_str())
2563 .unwrap_or("")
2564 .to_lowercase();
2565 f_stem.contains(&stem)
2566 || stem.contains(
2567 f_stem
2568 .trim_end_matches("tests")
2569 .trim_end_matches("test")
2570 .trim_end_matches("spec"),
2571 )
2572 })
2573 .collect()
2574}
2575
2576pub fn fetch_all_test_file_paths(db_path: &std::path::Path) -> Vec<String> {
2580 let Ok(db) = SearchFtsDb::open(db_path) else {
2581 return vec![];
2582 };
2583 let Ok(mut stmt) = db
2584 .conn
2585 .prepare("SELECT DISTINCT file_orig FROM asd_search_fts WHERE tier = 2")
2586 else {
2587 return vec![];
2588 };
2589 stmt.query_map([], |r| r.get::<_, String>(0))
2590 .map(|iter| iter.filter_map(|r| r.ok()).collect())
2591 .unwrap_or_default()
2592}
2593
2594pub fn test_files_for_source(all_test_files: &[String], source_file: &str) -> Vec<String> {
2598 let stem = std::path::Path::new(source_file)
2599 .file_stem()
2600 .and_then(|s| s.to_str())
2601 .unwrap_or("")
2602 .to_lowercase();
2603 if stem.is_empty() {
2604 return vec![];
2605 }
2606 all_test_files
2607 .iter()
2608 .filter(|f| {
2609 let f_stem = std::path::Path::new(f.as_str())
2610 .file_stem()
2611 .and_then(|s| s.to_str())
2612 .unwrap_or("")
2613 .to_lowercase();
2614 f_stem.contains(&stem)
2615 || stem.contains(
2616 f_stem
2617 .trim_end_matches("tests")
2618 .trim_end_matches("test")
2619 .trim_end_matches("spec"),
2620 )
2621 })
2622 .cloned()
2623 .collect()
2624}
2625
2626pub fn propose_test_path(source_file: &str) -> String {
2627 let path = std::path::Path::new(source_file);
2628 let stem = path
2629 .file_stem()
2630 .and_then(|s| s.to_str())
2631 .unwrap_or("Unknown");
2632 let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("swift");
2633 let candidate = source_file
2635 .replace("/Sources/", "/Tests/")
2636 .replace("/Source/", "/Tests/")
2637 .replace("/src/", "/tests/");
2638 let parent = if candidate != source_file {
2639 std::path::Path::new(&candidate)
2640 .parent()
2641 .and_then(|p| p.to_str())
2642 .unwrap_or("Tests")
2643 .to_string()
2644 } else {
2645 "Tests".to_string()
2646 };
2647 format!("{parent}/{stem}Tests.{ext}")
2648}
2649
2650pub fn propose_test_stub(source_file: &str, symbol_name: &str) -> String {
2661 let ext = std::path::Path::new(source_file)
2662 .extension()
2663 .and_then(|s| s.to_str())
2664 .unwrap_or("")
2665 .to_ascii_lowercase();
2666 let snake = to_snake_case(symbol_name);
2667 let pascal = to_pascal_case(symbol_name);
2668 match ext.as_str() {
2669 "py" => format!(
2670 "def test_{snake}():\n # arrange\n # act\n # assert\n raise NotImplementedError(\"test_{snake}: fill in\")\n"
2671 ),
2672 "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => format!(
2673 "test('{snake}', () => {{\n // arrange\n // act\n // assert\n throw new Error('test {snake}: fill in');\n}});\n"
2674 ),
2675 "rs" => format!(
2676 "#[test]\nfn {snake}() {{\n // arrange\n // act\n // assert\n todo!(\"{snake}: fill in\");\n}}\n"
2677 ),
2678 "go" => format!(
2679 "func Test{pascal}(t *testing.T) {{\n // arrange\n // act\n // assert\n t.Fatal(\"Test{pascal}: fill in\")\n}}\n"
2680 ),
2681 "java" => format!(
2682 "@Test\npublic void test{pascal}() {{\n // arrange\n // act\n // assert\n fail(\"test{pascal}: fill in\");\n}}\n"
2683 ),
2684 "cs" => format!(
2685 "[Test]\npublic void {pascal}_Should() {{\n // arrange\n // act\n // assert\n Assert.Fail(\"{pascal}_Should: fill in\");\n}}\n"
2686 ),
2687 "rb" => format!(
2688 "def test_{snake}\n # arrange\n # act\n # assert\n flunk(\"test_{snake}: fill in\")\nend\n"
2689 ),
2690 "kt" | "kts" => format!(
2691 "@Test\nfun `{snake}`() {{\n // arrange\n // act\n // assert\n fail(\"{snake}: fill in\")\n}}\n"
2692 ),
2693 "swift" => format!(
2694 "func test{pascal}() throws {{\n // arrange\n // act\n // assert\n XCTFail(\"test{pascal}: fill in\")\n}}\n"
2695 ),
2696 _ => {
2697 format!("// New test exercising {symbol_name} — fill in the arrange / act / assert.\n")
2698 }
2699 }
2700}
2701
2702fn to_snake_case(name: &str) -> String {
2704 let leaf = name.rsplit('.').next().unwrap_or(name);
2706 let mut out = String::new();
2707 let mut prev_lower = false;
2708 for ch in leaf.chars() {
2709 if ch == '_' || ch == '-' {
2710 if !out.ends_with('_') {
2711 out.push('_');
2712 }
2713 prev_lower = false;
2714 } else if ch.is_ascii_uppercase() {
2715 if prev_lower && !out.ends_with('_') {
2716 out.push('_');
2717 }
2718 out.push(ch.to_ascii_lowercase());
2719 prev_lower = false;
2720 } else {
2721 out.push(ch);
2722 prev_lower = ch.is_ascii_lowercase() || ch.is_ascii_digit();
2723 }
2724 }
2725 out.trim_matches('_').to_string()
2726}
2727
2728fn to_pascal_case(name: &str) -> String {
2730 let leaf = name.rsplit('.').next().unwrap_or(name);
2731 let mut out = String::new();
2732 let mut capitalize_next = true;
2733 for ch in leaf.chars() {
2734 if ch == '_' || ch == '-' {
2735 capitalize_next = true;
2736 } else if capitalize_next {
2737 out.push(ch.to_ascii_uppercase());
2738 capitalize_next = false;
2739 } else {
2740 out.push(ch);
2741 }
2742 }
2743 out
2744}
2745
2746pub fn gather_recency(
2760 scan_commits: usize,
2761 hot_days: f64,
2762) -> std::collections::HashMap<String, FileRecency> {
2763 use std::collections::HashMap;
2764 use std::process::Command;
2765
2766 let output = Command::new("git")
2767 .args([
2768 "log",
2769 &format!("-n{}", scan_commits),
2770 "--pretty=format:%ct",
2771 "--name-only",
2772 ])
2773 .output();
2774
2775 let Ok(out) = output else {
2776 return HashMap::new();
2777 };
2778 if !out.status.success() {
2779 return HashMap::new();
2780 }
2781
2782 let now_secs = std::time::SystemTime::now()
2783 .duration_since(std::time::UNIX_EPOCH)
2784 .map(|d| d.as_secs() as f64)
2785 .unwrap_or(0.0);
2786 let hot_secs = hot_days * 86400.0;
2787
2788 let text = String::from_utf8_lossy(&out.stdout);
2789 let mut current_ts: Option<f64> = None;
2790 let mut map: HashMap<String, FileRecency> = HashMap::new();
2791
2792 for line in text.lines() {
2793 let trimmed = line.trim();
2794 if trimmed.is_empty() {
2795 continue;
2796 }
2797 if let Ok(ts) = trimmed.parse::<f64>() {
2798 current_ts = Some(ts);
2799 } else if let Some(ts) = current_ts {
2800 map.entry(trimmed.to_string()).or_insert_with(|| {
2802 let days = (now_secs - ts) / 86400.0;
2803 FileRecency {
2804 last_touched_days: Some(days.max(0.0)),
2805 hot: (now_secs - ts) <= hot_secs,
2806 }
2807 });
2808 }
2809 }
2810 map
2811}
2812
2813#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2819#[serde(rename_all = "snake_case")]
2820pub enum OwnerSignalSource {
2821 GitBlame,
2823 DocComment,
2825 GitLog,
2827 LedgerTruth,
2829}
2830
2831#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2833pub struct AnnotatedOwner {
2834 pub name: String,
2835 pub source: OwnerSignalSource,
2836}
2837
2838#[derive(Debug, Clone)]
2840pub struct OwnershipSignal {
2841 pub primary_author: Option<String>,
2843 pub doc_owner: Option<String>,
2845 pub recent_committers: Vec<String>,
2847 pub annotated: Vec<AnnotatedOwner>,
2849}
2850
2851pub fn discover_symbol_ownership(
2857 file: &str,
2858 start_line: u32,
2859 end_line: u32,
2860 doc: Option<&str>,
2861) -> OwnershipSignal {
2862 use std::collections::HashMap;
2863 use std::process::Command;
2864
2865 let doc_owner = doc.and_then(|d| {
2867 for line in d.lines() {
2868 let l = line.trim().to_lowercase();
2869 for prefix in &["@owner ", "owner: ", "owned by "] {
2870 if let Some(rest) = l.strip_prefix(prefix) {
2871 let owner = rest.split_whitespace().next().unwrap_or("").to_string();
2872 if !owner.is_empty() {
2873 return Some(owner);
2874 }
2875 }
2876 }
2877 }
2878 None
2879 });
2880
2881 let blame_out = Command::new("git")
2883 .args([
2884 "blame",
2885 "--porcelain",
2886 &format!("-L{},{}", start_line.max(1), end_line.max(start_line)),
2887 "--",
2888 file,
2889 ])
2890 .output();
2891 let mut blame_authors: HashMap<String, usize> = HashMap::new();
2892 if let Ok(out) = blame_out {
2893 if out.status.success() {
2894 for line in String::from_utf8_lossy(&out.stdout).lines() {
2895 if let Some(author) = line.strip_prefix("author ") {
2896 *blame_authors.entry(author.trim().to_string()).or_insert(0) += 1;
2897 }
2898 }
2899 }
2900 }
2901 let primary_author = blame_authors
2902 .into_iter()
2903 .max_by_key(|(_, count)| *count)
2904 .map(|(a, _)| a)
2905 .filter(|a| !a.is_empty() && a != "Not Committed Yet");
2906
2907 let log_out = Command::new("git")
2909 .args(["log", "-n20", "--pretty=format:%an", "--", file])
2910 .output();
2911 let mut seen = std::collections::HashSet::new();
2912 let mut recent_committers: Vec<String> = Vec::new();
2913 if let Ok(out) = log_out {
2914 if out.status.success() {
2915 for author in String::from_utf8_lossy(&out.stdout).lines() {
2916 let a = author.trim().to_string();
2917 if !a.is_empty() && seen.insert(a.clone()) {
2918 recent_committers.push(a);
2919 if recent_committers.len() >= 5 {
2920 break;
2921 }
2922 }
2923 }
2924 }
2925 }
2926
2927 let mut annotated: Vec<AnnotatedOwner> = Vec::new();
2929 if let Some(ref owner) = doc_owner {
2931 annotated.push(AnnotatedOwner {
2932 name: owner.clone(),
2933 source: OwnerSignalSource::DocComment,
2934 });
2935 }
2936 if let Some(ref author) = primary_author {
2938 if !annotated.iter().any(|a| &a.name == author) {
2939 annotated.push(AnnotatedOwner {
2940 name: author.clone(),
2941 source: OwnerSignalSource::GitBlame,
2942 });
2943 }
2944 }
2945 for committer in &recent_committers {
2947 if !annotated.iter().any(|a| &a.name == committer) {
2948 annotated.push(AnnotatedOwner {
2949 name: committer.clone(),
2950 source: OwnerSignalSource::GitLog,
2951 });
2952 }
2953 }
2954
2955 OwnershipSignal {
2956 primary_author,
2957 doc_owner,
2958 recent_committers,
2959 annotated,
2960 }
2961}
2962
2963#[derive(Debug, Clone)]
2965pub struct CoveringTest {
2966 pub qname: String,
2967 pub file: String,
2968 pub line: i64,
2969 pub run_command: String,
2971}
2972
2973pub fn find_covering_tests(fts: Option<&SearchFtsDb>, impl_qname: &str) -> Vec<CoveringTest> {
2982 let leaf = impl_qname
2983 .split(|c: char| c == '.' || c == ':' || c == '/')
2984 .last()
2985 .unwrap_or(impl_qname)
2986 .to_lowercase();
2987 if leaf.is_empty() || leaf.len() < 3 {
2988 return vec![];
2989 }
2990 let Some(db) = fts else {
2991 return vec![];
2992 };
2993 let Ok(mut stmt) = db
2995 .conn
2996 .prepare("SELECT qname_orig, file_orig, start_line FROM asd_search_fts WHERE tier = 2")
2997 else {
2998 return vec![];
2999 };
3000 let rows: Vec<(String, String, i64)> = stmt
3001 .query_map([], |r| {
3002 Ok((
3003 r.get::<_, String>(0)?,
3004 r.get::<_, String>(1)?,
3005 r.get::<_, i64>(2)?,
3006 ))
3007 })
3008 .map(|iter| iter.filter_map(|r| r.ok()).collect())
3009 .unwrap_or_default();
3010
3011 rows.into_iter()
3012 .filter(|(qname, _, _)| {
3013 let q = qname.to_lowercase();
3014 let stripped = q
3015 .trim_start_matches("test")
3016 .trim_start_matches('_')
3017 .trim_start_matches("spec")
3018 .trim_start_matches('_');
3019 stripped.contains(leaf.as_str()) || q.contains(leaf.as_str())
3020 })
3021 .map(|(qname, file, line)| {
3022 let run_command = derive_test_run_command(&file, &qname);
3023 CoveringTest {
3024 qname,
3025 file,
3026 line,
3027 run_command,
3028 }
3029 })
3030 .collect()
3031}
3032
3033fn derive_test_run_command(file: &str, test_qname: &str) -> String {
3038 use std::path::Path;
3039
3040 let file_path = Path::new(file);
3041 let leaf_name = test_qname
3042 .split(|c: char| c == '.' || c == ':')
3043 .last()
3044 .unwrap_or(test_qname);
3045
3046 let mut dir = file_path.parent();
3048 while let Some(d) = dir {
3049 if d.join("Package.swift").exists() {
3050 return format!("swift test --filter {}", leaf_name);
3051 }
3052 if d.join("Cargo.toml").exists() {
3053 return format!("cargo test {}", leaf_name);
3054 }
3055 if d.join("package.json").exists() {
3056 if file.ends_with(".test.ts")
3058 || file.ends_with(".spec.ts")
3059 || file.ends_with(".test.js")
3060 || file.ends_with(".spec.js")
3061 {
3062 return format!("npx jest --testNamePattern=\"{}\"", leaf_name);
3063 }
3064 return format!("npm test -- --grep \"{}\"", leaf_name);
3065 }
3066 if d.join("pyproject.toml").exists() || d.join("setup.py").exists() {
3067 return format!("pytest -k \"{}\"", leaf_name);
3068 }
3069 if d.join("go.mod").exists() {
3070 let rel = file_path
3072 .strip_prefix(d)
3073 .ok()
3074 .and_then(|p| p.parent())
3075 .and_then(|p| p.to_str())
3076 .map(|s| format!("./{}", s))
3077 .unwrap_or_else(|| "./...".to_string());
3078 return format!("go test {} -run {}", rel, leaf_name);
3079 }
3080 if d.join("Gemfile").exists() {
3081 return format!("bundle exec rspec --example \"{}\"", leaf_name);
3082 }
3083 if d.join("build.gradle").exists() || d.join("build.gradle.kts").exists() {
3084 return format!("./gradlew test --tests \"*{}*\"", leaf_name);
3085 }
3086 if d.join("pom.xml").exists() {
3087 return format!("mvn -Dtest=*{}* test", leaf_name);
3088 }
3089 dir = d.parent();
3090 }
3091
3092 if file.ends_with(".swift") {
3094 format!("swift test --filter {}", leaf_name)
3095 } else if file.ends_with(".rs") {
3096 format!("cargo test {}", leaf_name)
3097 } else if file.ends_with(".py") {
3098 format!("pytest -k \"{}\"", leaf_name)
3099 } else if file.ends_with(".go") {
3100 format!("go test ./... -run {}", leaf_name)
3101 } else {
3102 format!("# run: {}", leaf_name)
3103 }
3104}
3105
3106fn strip_doc_prefix(line: &str) -> &str {
3116 let s = line.trim();
3117 for prefix in &["///", "//!", "/**", "*/", "//"] {
3119 if let Some(rest) = s.strip_prefix(prefix) {
3120 return rest
3121 .trim_start_matches([' ', '\t'])
3122 .trim_end_matches([' ', '\t']);
3123 }
3124 }
3125 if s.starts_with("* ") || s == "*" {
3127 return s[1..].trim_start_matches([' ', '\t']);
3128 }
3129 for prefix in &["#", "--"] {
3130 if let Some(rest) = s.strip_prefix(prefix) {
3131 return rest.trim_start_matches([' ', '\t']);
3132 }
3133 }
3134 s
3135}
3136
3137pub fn extract_summary(doc: Option<&str>, signature: Option<&str>) -> String {
3138 if let Some(d) = doc {
3139 let cleaned: String = d
3141 .lines()
3142 .map(strip_doc_prefix)
3143 .collect::<Vec<_>>()
3144 .join(" ")
3145 .trim()
3146 .to_string();
3147 if !cleaned.is_empty() {
3148 let end = cleaned
3149 .char_indices()
3150 .find_map(|(i, c)| {
3151 if matches!(c, '.' | '!' | '?') {
3152 Some(i + c.len_utf8())
3153 } else {
3154 None
3155 }
3156 })
3157 .unwrap_or(cleaned.len().min(120));
3158 let sentence = cleaned[..end.min(cleaned.len())]
3159 .trim()
3160 .trim_end_matches(['.', '!', '?']);
3161 if !sentence.is_empty() {
3162 return sentence.to_string();
3163 }
3164 }
3165 }
3166 if let Some(sig) = signature {
3167 let trimmed = sig.trim();
3168 if !trimmed.is_empty() {
3169 return trimmed.chars().take(100).collect();
3170 }
3171 }
3172 String::new()
3173}
3174
3175pub fn symbol_tier(file: &str) -> SymbolTier {
3186 if is_test_file(file) {
3187 2
3188 } else if is_utility_file(file) {
3189 1
3190 } else {
3191 0
3192 }
3193}
3194
3195pub fn load_layer_overrides(db_path: &std::path::Path) -> Vec<(String, String)> {
3210 let candidates = [
3211 db_path.parent().map(|p| p.join(".asd").join("layers.toml")),
3212 db_path.parent().map(|p| p.join("layers.toml")),
3213 ];
3214
3215 for maybe_path in candidates.iter().flatten() {
3216 let Ok(text) = std::fs::read_to_string(maybe_path) else {
3217 continue;
3218 };
3219 #[derive(serde::Deserialize)]
3220 struct LayersFile {
3221 patterns: Option<toml::Table>,
3222 }
3223 let Ok(parsed) = toml::from_str::<LayersFile>(&text) else {
3224 continue;
3225 };
3226 let Some(patterns) = parsed.patterns else {
3227 continue;
3228 };
3229 let pairs: Vec<(String, String)> = patterns
3230 .into_iter()
3231 .filter_map(|(k, v)| {
3232 let layer = v.as_str()?;
3233 validate_layer_name(layer).map(|l| (k.to_lowercase(), l.to_string()))
3234 })
3235 .collect();
3236 if !pairs.is_empty() {
3237 return pairs;
3238 }
3239 }
3240 vec![]
3241}
3242
3243fn validate_layer_name(name: &str) -> Option<&'static str> {
3244 match name {
3245 "ui" => Some("ui"),
3246 "viewmodel" => Some("viewmodel"),
3247 "scheduler" => Some("scheduler"),
3248 "core_model" => Some("core_model"),
3249 "persistence" => Some("persistence"),
3250 "utility" => Some("utility"),
3251 "tests" => Some("tests"),
3252 "other" => Some("other"),
3253 _ => None,
3254 }
3255}
3256
3257pub fn classify_layer(
3271 file: &str,
3272 tier: SymbolTier,
3273 overrides: &[(String, String)],
3274) -> &'static str {
3275 if tier == 2 {
3276 return "tests";
3277 }
3278 if tier == 1 {
3279 return "utility";
3280 }
3281
3282 let lower = file.to_lowercase();
3284 for (pattern, layer) in overrides {
3285 if lower.contains(pattern.as_str()) {
3286 if let Some(l) = validate_layer_name(layer) {
3287 return l;
3288 }
3289 }
3290 }
3291
3292 let segments: Vec<&str> = lower.split(|c| c == '/' || c == '\\').collect();
3293 let filename = segments.last().copied().unwrap_or("");
3294 let stem = filename.rsplit('.').nth(1).unwrap_or(filename);
3296
3297 let dirs: Vec<&str> = segments.iter().copied().rev().skip(1).collect();
3299
3300 const SCHED_DIRS: &[&str] = &[
3302 "scheduler",
3303 "schedulers",
3304 "engine",
3305 "engines",
3306 "audio",
3307 "audioengine",
3308 "transport",
3309 "render",
3310 "renderer",
3311 "pipeline",
3312 "worker",
3313 "workers",
3314 "processing",
3315 "realtime",
3316 "dsp",
3317 "clock",
3318 ];
3319 const SCHED_SUFFIXES: &[&str] = &[
3320 "scheduler",
3321 "engine",
3322 "transport",
3323 "renderer",
3324 "pipeline",
3325 "worker",
3326 "processor",
3327 "synthesizer",
3328 "synth",
3329 "clock",
3330 "timer",
3331 "compiler",
3332 "loop",
3333 ];
3334 if dirs.iter().any(|d| SCHED_DIRS.contains(d))
3335 || SCHED_SUFFIXES.iter().any(|s| stem.ends_with(s))
3336 {
3337 return "scheduler";
3338 }
3339
3340 const PERSIST_DIRS: &[&str] = &[
3342 "storage",
3343 "database",
3344 "db",
3345 "repository",
3346 "repositories",
3347 "cache",
3348 "datastore",
3349 "persistence",
3350 "migration",
3351 "migrations",
3352 "store",
3353 "dao",
3354 ];
3355 const PERSIST_SUFFIXES: &[&str] = &[
3356 "repository",
3357 "store",
3358 "database",
3359 "cache",
3360 "storage",
3361 "dao",
3362 "datasource",
3363 "migration",
3364 ];
3365 if dirs.iter().any(|d| PERSIST_DIRS.contains(d))
3366 || PERSIST_SUFFIXES.iter().any(|s| stem.ends_with(s))
3367 {
3368 return "persistence";
3369 }
3370
3371 const VM_DIRS: &[&str] = &[
3373 "viewmodels",
3374 "viewmodel",
3375 "presenters",
3376 "presenter",
3377 "coordinators",
3378 "coordinator",
3379 "interactors",
3380 "interactor",
3381 "controllers",
3382 "controller",
3383 "states",
3384 "state",
3385 "routers",
3386 "router",
3387 ];
3388 const VM_SUFFIXES: &[&str] = &[
3389 "viewmodel",
3390 "viewstate",
3391 "presenter",
3392 "coordinator",
3393 "interactor",
3394 "statemanager",
3395 "controller",
3396 "observable",
3397 "environment",
3398 "router",
3399 ];
3400 if dirs.iter().any(|d| VM_DIRS.contains(d)) || VM_SUFFIXES.iter().any(|s| stem.ends_with(s)) {
3401 return "viewmodel";
3402 }
3403
3404 const UI_DIRS: &[&str] = &[
3406 "views",
3407 "view",
3408 "screens",
3409 "screen",
3410 "pages",
3411 "page",
3412 "components",
3413 "component",
3414 "widgets",
3415 "widget",
3416 "cells",
3417 "viewcontrollers",
3418 "ui",
3419 "fragments",
3420 ];
3421 const UI_SUFFIXES: &[&str] = &[
3422 "view",
3423 "screen",
3424 "page",
3425 "component",
3426 "widget",
3427 "cell",
3428 "viewcontroller",
3429 "fragment",
3430 "layout",
3431 "button",
3432 "label",
3433 "panel",
3434 "sheet",
3435 "modal",
3436 "overlay",
3437 "header",
3438 "footer",
3439 ];
3440 if dirs.iter().any(|d| UI_DIRS.contains(d)) || UI_SUFFIXES.iter().any(|s| stem.ends_with(s)) {
3441 return "ui";
3442 }
3443
3444 const MODEL_DIRS: &[&str] = &[
3446 "models", "model", "domain", "core", "entities", "entity", "services", "service",
3447 "usecases", "usecase", "business", "logic", "features", "feature",
3448 ];
3449 const MODEL_SUFFIXES: &[&str] = &[
3450 "model",
3451 "entity",
3452 "service",
3453 "usecase",
3454 "manager",
3455 "handler",
3456 "factory",
3457 "builder",
3458 "validator",
3459 ];
3460 if dirs.iter().any(|d| MODEL_DIRS.contains(d))
3461 || MODEL_SUFFIXES.iter().any(|s| stem.ends_with(s))
3462 {
3463 return "core_model";
3464 }
3465
3466 "other"
3467}
3468
3469pub fn classify_file_role(file: &str) -> &'static str {
3488 let fl = format!("/{}", file.to_lowercase());
3494 if fl.contains("/test")
3496 || fl.contains("/tests/")
3497 || fl.contains("/spec")
3498 || fl.contains("_test.")
3499 || fl.contains("spec.")
3500 || fl.ends_with("tests.swift")
3501 {
3502 return "test";
3503 }
3504 if fl.contains("/example")
3505 || fl.contains("/examples")
3506 || fl.contains("/sample")
3507 || fl.contains("/samples")
3508 || fl.contains("/demo")
3509 || fl.contains("/demos")
3510 {
3511 return "example";
3512 }
3513 if fl.contains("/fixture")
3514 || fl.contains("/fixtures")
3515 || fl.contains("/seed")
3516 || fl.contains("/seeds")
3517 {
3518 return "fixture";
3519 }
3520 if fl.contains("/script")
3521 || fl.contains("/scripts")
3522 || fl.contains("/tool/")
3523 || fl.contains("/tools/")
3524 || fl.contains("/bin/")
3525 || fl.contains("/hack/")
3526 {
3527 return "script";
3528 }
3529 if fl.contains("/generated")
3530 || fl.contains("/gen/")
3531 || fl.contains(".generated.")
3532 || fl.contains(".pb.")
3533 || fl.contains("_generated")
3534 {
3535 return "generated";
3536 }
3537 if fl.contains("/doc")
3538 || fl.contains("/docs")
3539 || fl.contains("/reference")
3540 || fl.contains("readme")
3541 || fl.ends_with(".md")
3542 || fl.ends_with(".rst")
3543 || fl.ends_with(".adoc")
3544 {
3545 return "reference";
3546 }
3547 if fl.contains("/viewmodels/") || fl.contains("/viewmodel/") || stem_ends_with(&fl, "viewmodel")
3551 {
3552 return "viewmodel";
3553 }
3554 if fl.contains("/views/")
3555 || fl.contains("/view/")
3556 || stem_ends_with(&fl, "view")
3557 || fl.ends_with(".vue")
3558 || fl.ends_with(".svelte")
3559 {
3560 return "view";
3561 }
3562 "impl"
3563}
3564
3565fn stem_ends_with(path_lower: &str, suffix: &str) -> bool {
3569 let stem = std::path::Path::new(path_lower)
3570 .file_stem()
3571 .and_then(|s| s.to_str())
3572 .unwrap_or("");
3573 stem.ends_with(suffix)
3574}
3575
3576pub fn classify_layer_sym(
3581 file: &str,
3582 qname: &str,
3583 tier: SymbolTier,
3584 overrides: &[(String, String)],
3585) -> &'static str {
3586 let layer = classify_layer(file, tier, overrides);
3587 if layer != "other" {
3588 return layer;
3589 }
3590 let components: Vec<&str> = qname
3595 .split(|c| c == '.' || c == ':' || c == '/')
3596 .filter(|s| !s.is_empty())
3597 .collect();
3598
3599 let mut found_viewmodel = false;
3600 let mut found_ui = false;
3601 let mut found_scheduler = false;
3602 let mut found_persistence = false;
3603 let mut found_core_model = false;
3604
3605 for component in &components {
3606 let n = component.to_lowercase();
3607 if n.ends_with("viewmodel")
3608 || n.ends_with("controller")
3609 || n.ends_with("presenter")
3610 || n.ends_with("coordinator")
3611 || n.ends_with("interactor")
3612 || n.ends_with("viewstate")
3613 || n.ends_with("statemanager")
3614 || n.ends_with("router")
3615 {
3616 found_viewmodel = true;
3617 } else if n.ends_with("view")
3618 || n.ends_with("screen")
3619 || n.ends_with("page")
3620 || n.ends_with("cell")
3621 || n.ends_with("widget")
3622 || n.ends_with("button")
3623 || n.ends_with("label")
3624 || n.ends_with("panel")
3625 || n.ends_with("viewcontroller")
3626 || n.ends_with("sheet")
3627 || n.ends_with("overlay")
3628 || n.ends_with("header")
3629 {
3630 found_ui = true;
3631 } else if n.ends_with("scheduler")
3632 || n.ends_with("engine")
3633 || n.ends_with("compiler")
3634 || n.ends_with("processor")
3635 || n.ends_with("renderer")
3636 || n.ends_with("clock")
3637 || n.ends_with("timer")
3638 || n.ends_with("pipeline")
3639 || n.ends_with("worker")
3640 || n.ends_with("synthesizer")
3641 || n.ends_with("transport")
3642 {
3643 found_scheduler = true;
3644 } else if n.ends_with("repository")
3645 || n.ends_with("store")
3646 || n.ends_with("cache")
3647 || n.ends_with("dao")
3648 || n.ends_with("database")
3649 || n.ends_with("datasource")
3650 {
3651 found_persistence = true;
3652 } else if n.ends_with("model")
3653 || n.ends_with("entity")
3654 || n.ends_with("service")
3655 || n.ends_with("manager")
3656 || n.ends_with("handler")
3657 || n.ends_with("factory")
3658 || n.ends_with("validator")
3659 || n.ends_with("usecase")
3660 || n.ends_with("builder")
3661 {
3662 found_core_model = true;
3663 }
3664 }
3665
3666 if found_viewmodel {
3668 return "viewmodel";
3669 }
3670 if found_ui {
3671 return "ui";
3672 }
3673 if found_scheduler {
3674 return "scheduler";
3675 }
3676 if found_persistence {
3677 return "persistence";
3678 }
3679 if found_core_model {
3680 return "core_model";
3681 }
3682 "other"
3683}
3684
3685fn is_utility_file(file: &str) -> bool {
3689 let lower = file.to_lowercase();
3690 let segments: Vec<&str> = lower.split(|c| c == '/' || c == '\\').collect();
3691
3692 const UTILITY_DIRS: &[&str] = &[
3694 "previews",
3695 "preview",
3696 "samples",
3697 "sample",
3698 "sampledata",
3699 "examples",
3700 "example",
3701 "mocks",
3702 "mock",
3703 "stubs",
3704 "stub",
3705 "fixtures",
3706 "fixture",
3707 "demo",
3708 "demos",
3709 "editor",
3710 "editors",
3711 "generated",
3712 "gen",
3713 "sandbox",
3714 "playground",
3715 "playgrounds",
3716 ];
3717 const UTILITY_PREFIXES: &[&str] = &["mock", "stub", "fake", "sample", "preview", "generated"];
3719 if segments
3720 .iter()
3721 .rev()
3722 .skip(1)
3723 .any(|s| UTILITY_DIRS.contains(s) || UTILITY_PREFIXES.iter().any(|p| s.starts_with(p)))
3724 {
3725 return true;
3726 }
3727
3728 if let Some(filename) = segments.last() {
3730 if filename.contains("preview") || filename.contains("_previews") {
3732 return true;
3733 }
3734 if filename.contains("sampledata") || filename.contains("sample_data") {
3736 return true;
3737 }
3738 if filename.contains(".generated.")
3740 || filename.contains(".g.")
3741 || filename.ends_with("generated.swift")
3742 {
3743 return true;
3744 }
3745 if filename.starts_with("mock")
3747 || filename.contains("_mock.")
3748 || filename.starts_with("stub")
3749 || filename.contains("_stub.")
3750 || filename.starts_with("fake")
3751 || filename.contains("_fake.")
3752 {
3753 return true;
3754 }
3755 if filename.ends_with(".playground") {
3757 return true;
3758 }
3759 }
3760
3761 false
3762}
3763
3764fn is_test_file(file: &str) -> bool {
3769 let lower = file.to_lowercase();
3770 let segments: Vec<&str> = lower.split(|c| c == '/' || c == '\\').collect();
3771
3772 const TEST_DIRS: &[&str] = &[
3774 "tests",
3775 "test",
3776 "specs",
3777 "spec",
3778 "__tests__",
3779 "__mocks__",
3780 "testing",
3781 "testcases",
3782 ];
3783 if segments.iter().rev().skip(1).any(|s| TEST_DIRS.contains(s)) {
3784 return true;
3785 }
3786
3787 if let Some(filename) = segments.last() {
3789 if filename.starts_with("test_") || filename.ends_with("_test.py") {
3791 return true;
3792 }
3793 if filename.ends_with("_test.go") {
3795 return true;
3796 }
3797 if filename.ends_with("_test.rs") {
3799 return true;
3800 }
3801 if filename.ends_with("tests.swift") || filename.ends_with("spec.swift") {
3803 return true;
3804 }
3805 if filename.contains(".test.") || filename.contains(".spec.") {
3807 return true;
3808 }
3809 if filename.ends_with("_spec.rb") {
3811 return true;
3812 }
3813 if filename.ends_with("test.java")
3815 || filename.ends_with("tests.java")
3816 || filename.ends_with("test.kt")
3817 || filename.ends_with("tests.kt")
3818 {
3819 return true;
3820 }
3821 if filename.ends_with("tests.cs") || filename.ends_with("test.cs") {
3823 return true;
3824 }
3825 }
3826
3827 false
3828}
3829
3830fn expand_identifier(qname: &str) -> String {
3837 let mut parts: Vec<String> = vec![qname.to_string()];
3838
3839 for segment in qname.split('.') {
3840 parts.push(segment.to_lowercase());
3841 for word in split_camel(segment) {
3842 let w = word.to_lowercase();
3843 if w.len() >= 2 {
3844 parts.push(w);
3845 }
3846 }
3847 }
3848
3849 parts.dedup();
3850 parts.join(" ")
3851}
3852
3853fn expand_text(text: &str) -> String {
3856 let mut words: Vec<String> = vec![text.to_string()];
3857
3858 let tokens: Vec<&str> = text
3859 .split(|c: char| !c.is_alphanumeric())
3860 .filter(|s| s.len() >= 2)
3861 .collect();
3862
3863 for tok in tokens {
3864 words.push(tok.to_lowercase());
3865 for word in split_camel(tok) {
3866 let w = word.to_lowercase();
3867 if w.len() >= 2 {
3868 words.push(w);
3869 }
3870 }
3871 }
3872
3873 words.dedup();
3874 words.join(" ")
3875}
3876
3877fn split_camel(s: &str) -> Vec<&str> {
3879 let bytes = s.as_bytes();
3880 let mut starts = vec![0usize];
3881
3882 for i in 1..bytes.len() {
3883 let prev = bytes[i - 1];
3884 let curr = bytes[i];
3885 if curr.is_ascii_uppercase() && (prev.is_ascii_lowercase() || prev.is_ascii_digit()) {
3887 starts.push(i);
3888 }
3889 if i + 1 < bytes.len()
3891 && prev.is_ascii_uppercase()
3892 && curr.is_ascii_uppercase()
3893 && bytes[i + 1].is_ascii_lowercase()
3894 {
3895 starts.push(i);
3896 }
3897 }
3898
3899 starts.push(bytes.len());
3900 starts
3901 .windows(2)
3902 .map(|w| &s[w[0]..w[1]])
3903 .filter(|seg| !seg.is_empty())
3904 .collect()
3905}
3906
3907#[derive(Debug, Clone)]
3917pub struct SearchDoc {
3918 pub doc_id: String,
3920 pub kind: DocKind,
3922 pub path: String,
3924 pub span_start: Option<u32>,
3926 pub title: String,
3928 pub body_text: String,
3930 pub owner_symbol_id: Option<String>,
3932}
3933
3934impl SearchDoc {
3935 pub fn new(
3936 kind: DocKind,
3937 path: impl Into<String>,
3938 span_start: Option<u32>,
3939 title: impl Into<String>,
3940 body_text: impl Into<String>,
3941 ) -> Self {
3942 let path = path.into();
3943 let span_str = span_start.map(|l| l.to_string()).unwrap_or_default();
3944 let raw = format!("{}:{}", path, span_str);
3945 let mut hash: u64 = 5381;
3947 for b in raw.bytes() {
3948 hash = hash.wrapping_mul(33).wrapping_add(b as u64);
3949 }
3950 let doc_id = format!("doc_{:016x}", hash);
3951 Self {
3952 doc_id,
3953 kind,
3954 path,
3955 span_start,
3956 title: title.into(),
3957 body_text: body_text.into(),
3958 owner_symbol_id: None,
3959 }
3960 }
3961}
3962
3963#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3965pub enum DocKind {
3966 Markdown,
3967 Config, Html,
3969 Css,
3970 Manifest, BuildScript, Other,
3973}
3974
3975impl DocKind {
3976 pub fn as_str(self) -> &'static str {
3977 match self {
3978 DocKind::Markdown => "markdown",
3979 DocKind::Config => "config",
3980 DocKind::Html => "html",
3981 DocKind::Css => "css",
3982 DocKind::Manifest => "manifest",
3983 DocKind::BuildScript => "build_script",
3984 DocKind::Other => "other",
3985 }
3986 }
3987}
3988
3989#[derive(Debug, Clone)]
3991pub struct DocHit {
3992 pub bm25_score: f64,
3993 pub doc_id: String,
3994 pub kind: String,
3995 pub path: String,
3996 pub span_start: Option<u32>,
3997 pub title: String,
3998 pub preview: String,
4000 pub owner_symbol_id: Option<String>,
4001}
4002
4003pub struct SearchDocsDb {
4005 conn: Connection,
4006}
4007
4008impl SearchDocsDb {
4009 const SCHEMA_VER: i64 = 1;
4010
4011 pub fn open(db_path: &Path) -> rusqlite::Result<Self> {
4012 let conn = Connection::open(db_path)?;
4013 conn.execute_batch(
4014 "PRAGMA journal_mode=WAL;
4015 PRAGMA synchronous=NORMAL;
4016 PRAGMA temp_store=MEMORY;
4017 -- Wait out short-lived writers (e.g. asd-serve holding a sync
4018 -- transaction) instead of failing instantly with SQLITE_BUSY.
4019 -- A failed cache sync used to leave the DB cold forever; the
4020 -- timeout plus the open-time self-heal makes that unreachable.
4021 PRAGMA busy_timeout=5000;",
4022 )?;
4023 let db_bytes = std::fs::metadata(db_path)
4024 .map(|m| m.len() as usize)
4025 .unwrap_or(0);
4026 let mut cache_kb: usize = ((db_bytes * 8 / 10) / 1024).clamp(8_192, 65_536);
4027 let mut mmap_bytes: u64 = 268_435_456;
4028 if let Some(project_dir) = db_path.parent() {
4029 let cfg_path = project_dir.join(".asd").join("config.toml");
4030 if let Ok(raw) = std::fs::read_to_string(&cfg_path) {
4031 if let Ok(table) = raw.parse::<toml::Table>() {
4032 if let Some(perf) = table.get("performance").and_then(|v| v.as_table()) {
4033 if let Some(v) = perf.get("cache_size_kb").and_then(|v| v.as_integer()) {
4034 cache_kb = (v as usize).clamp(1_024, 131_072);
4035 }
4036 if let Some(v) = perf.get("mmap_size_mb").and_then(|v| v.as_integer()) {
4037 mmap_bytes = (v as u64).clamp(64, 4096) * 1024 * 1024;
4038 }
4039 }
4040 }
4041 }
4042 }
4043 conn.execute_batch(&format!(
4044 "PRAGMA mmap_size={mmap_bytes}; PRAGMA cache_size=-{cache_kb};"
4045 ))?;
4046 let db = Self { conn };
4047 db.ensure_schema()?;
4048 Ok(db)
4049 }
4050
4051 fn ensure_schema(&self) -> rusqlite::Result<()> {
4052 let current: i64 = self
4053 .conn
4054 .query_row("SELECT version FROM asd_docs_meta LIMIT 1", [], |r| {
4055 r.get(0)
4056 })
4057 .unwrap_or(0);
4058
4059 if current != Self::SCHEMA_VER {
4060 self.conn.execute_batch(
4061 "DROP TABLE IF EXISTS asd_search_docs;
4062 DROP TABLE IF EXISTS asd_docs_meta;",
4063 )?;
4064 }
4065
4066 self.conn.execute_batch(&format!(
4067 "CREATE VIRTUAL TABLE IF NOT EXISTS asd_search_docs USING fts5(
4068 doc_id UNINDEXED,
4069 kind UNINDEXED,
4070 path UNINDEXED,
4071 span_start UNINDEXED,
4072 owner_symbol_id UNINDEXED,
4073 title,
4074 body_text,
4075 tokenize = 'unicode61 remove_diacritics 1'
4076 );
4077 CREATE TABLE IF NOT EXISTS asd_docs_meta (version INTEGER PRIMARY KEY);
4078 INSERT OR IGNORE INTO asd_docs_meta VALUES ({});",
4079 Self::SCHEMA_VER
4080 ))
4081 }
4082
4083 pub fn rebuild(&self, docs: &[SearchDoc]) -> rusqlite::Result<()> {
4085 self.conn.execute_batch("DELETE FROM asd_search_docs;")?;
4086 for doc in docs {
4087 self.conn.execute(
4088 "INSERT INTO asd_search_docs(doc_id, kind, path, span_start, owner_symbol_id, title, body_text)
4089 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
4090 params![
4091 doc.doc_id,
4092 doc.kind.as_str(),
4093 doc.path,
4094 doc.span_start,
4095 doc.owner_symbol_id,
4096 doc.title,
4097 doc.body_text,
4098 ],
4099 )?;
4100 }
4101 Ok(())
4102 }
4103
4104 pub fn count(&self) -> rusqlite::Result<i64> {
4105 self.conn
4106 .query_row("SELECT COUNT(*) FROM asd_search_docs", [], |r| r.get(0))
4107 }
4108
4109 pub fn search(
4111 &self,
4112 tokens: &[String],
4113 limit: usize,
4114 kinds: Option<&[&str]>,
4115 ) -> rusqlite::Result<Vec<DocHit>> {
4116 let filtered: Vec<&String> = tokens.iter().filter(|t| !is_stopword(t)).collect();
4117 if filtered.is_empty() {
4118 return Ok(vec![]);
4119 }
4120 let match_expr = filtered
4121 .iter()
4122 .map(|t| format!("\"{}\"", t.replace('"', "")))
4123 .collect::<Vec<_>>()
4124 .join(" OR ");
4125
4126 let kind_filter = kinds
4127 .map(|ks| {
4128 let list = ks
4129 .iter()
4130 .map(|k| format!("'{}'", k))
4131 .collect::<Vec<_>>()
4132 .join(",");
4133 format!(" AND kind IN ({})", list)
4134 })
4135 .unwrap_or_default();
4136
4137 let sql = format!(
4138 "SELECT doc_id, kind, path, span_start, owner_symbol_id, title, body_text,
4139 -bm25(asd_search_docs, 5.0, 3.0) AS score
4140 FROM asd_search_docs
4141 WHERE asd_search_docs MATCH ?1{kind_filter}
4142 ORDER BY score DESC
4143 LIMIT {limit}"
4144 );
4145
4146 let mut stmt = self.conn.prepare(&sql)?;
4147 let hits = stmt
4148 .query_map(params![match_expr], |row| {
4149 let body: String = row.get(6)?;
4150 let preview = body.chars().take(200).collect::<String>();
4151 Ok(DocHit {
4152 doc_id: row.get(0)?,
4153 kind: row.get(1)?,
4154 path: row.get(2)?,
4155 span_start: row.get(3)?,
4156 owner_symbol_id: row.get(4)?,
4157 title: row.get(5)?,
4158 preview,
4159 bm25_score: row.get(7)?,
4160 })
4161 })?
4162 .filter_map(|r| r.ok())
4163 .collect();
4164 Ok(hits)
4165 }
4166
4167 pub fn is_empty(&self) -> bool {
4168 self.count().unwrap_or(0) == 0
4169 }
4170}
4171
4172pub fn effect_detail_reason(decl: Option<&crate::schema::EffectDecl>) -> String {
4184 let Some(decl) = decl else {
4185 return "no effects declared".to_string();
4186 };
4187 if decl.declared.is_empty() {
4188 return "no effects declared".to_string();
4189 }
4190 if let Some(rt) = &decl.runtime {
4194 let total = rt.confirmations + rt.contradictions;
4195 if total > 0 {
4196 let verdict = if rt.contradictions == 0 {
4197 "runtime-verified"
4198 } else {
4199 "runtime-contested"
4200 };
4201 return format!(
4202 "{} — confidence {:.2} ({}✓/{}✗ over {} trace{})",
4203 verdict,
4204 rt.confidence(),
4205 rt.confirmations,
4206 rt.contradictions,
4207 total,
4208 if total == 1 { "" } else { "s" },
4209 );
4210 }
4211 }
4212 let Some(ref v) = decl.verification else {
4213 return format!(
4214 "effects declared ({} effect{}) but not verified — run 'asd verify-effects'",
4215 decl.declared.len(),
4216 if decl.declared.len() == 1 { "" } else { "s" }
4217 );
4218 };
4219 match v.status {
4220 crate::schema::VerificationStatus::Ok => {
4221 let source = format!("{:?}", v.by).to_lowercase().replace("_", "-");
4222 let date = v.at.format("%Y-%m-%d").to_string();
4223 format!("ok — verified by {} at {}", source, date)
4224 }
4225 crate::schema::VerificationStatus::Unverified => {
4226 "unverified — verification run did not complete".to_string()
4227 }
4228 crate::schema::VerificationStatus::Mismatch => {
4229 if v.mismatches.is_empty() {
4230 "mismatch — declared effects not observed at runtime".to_string()
4231 } else {
4232 let mm = &v.mismatches[0];
4233 format!("mismatch — {} for {:?}", mm.kind, mm.effect)
4234 }
4235 }
4236 }
4237}
4238
4239#[cfg(test)]
4240mod tests {
4241 use super::*;
4242
4243 fn decl_with_runtime(conf: u64, contra: u64, prior: f64) -> crate::schema::EffectDecl {
4246 use crate::schema::*;
4247 EffectDecl {
4248 symbol_id: "s".into(),
4249 declared: vec![Effect::new(EffectCategory::IoNetOut)],
4250 transitive: vec![],
4251 verification: Some(Verification {
4254 by: VerificationSource::RuntimeTracer,
4255 at: chrono::Utc::now(),
4256 status: if contra == 0 {
4257 VerificationStatus::Ok
4258 } else {
4259 VerificationStatus::Mismatch
4260 },
4261 mismatches: vec![],
4262 }),
4263 confidence: Some(RuntimeEvidence::derive_confidence(prior, conf, contra)),
4264 runtime: Some(RuntimeEvidence {
4265 confirmations: conf,
4266 contradictions: contra,
4267 prior,
4268 last_trace_id: Some("trc".into()),
4269 last_observed_at: chrono::Utc::now(),
4270 }),
4271 matched_policy: None,
4272 }
4273 }
4274
4275 #[test]
4276 fn effect_detail_runtime_verified_badge() {
4277 let label = effect_detail_reason(Some(&decl_with_runtime(5, 0, 0.5)));
4278 assert!(label.starts_with("runtime-verified"), "got: {label}");
4279 assert!(label.contains("5✓/0✗"), "got: {label}");
4280 assert!(label.contains("over 5 traces"), "got: {label}");
4281 assert!(label.contains("confidence 0."), "got: {label}");
4282 }
4283
4284 #[test]
4285 fn effect_detail_runtime_contested_when_contradictions() {
4286 let label = effect_detail_reason(Some(&decl_with_runtime(2, 3, 0.5)));
4287 assert!(label.starts_with("runtime-contested"), "got: {label}");
4288 assert!(label.contains("2✓/3✗"), "got: {label}");
4289 }
4290
4291 #[test]
4292 fn effect_detail_single_trace_is_singular() {
4293 let label = effect_detail_reason(Some(&decl_with_runtime(1, 0, 0.5)));
4294 assert!(
4295 label.contains("over 1 trace)"),
4296 "expected singular, got: {label}"
4297 );
4298 }
4299
4300 #[test]
4301 fn effect_detail_runtime_takes_precedence_over_static_label() {
4302 let label = effect_detail_reason(Some(&decl_with_runtime(3, 0, 0.6)));
4305 assert!(
4306 !label.starts_with("ok — verified"),
4307 "static label leaked: {label}"
4308 );
4309 }
4310
4311 #[test]
4312 fn effect_detail_falls_back_to_static_without_runtime() {
4313 use crate::schema::*;
4314 let decl = EffectDecl {
4315 symbol_id: "s".into(),
4316 declared: vec![Effect::new(EffectCategory::IoNetOut)],
4317 transitive: vec![],
4318 verification: Some(Verification {
4319 by: VerificationSource::StaticChecker,
4320 at: chrono::Utc::now(),
4321 status: VerificationStatus::Ok,
4322 mismatches: vec![],
4323 }),
4324 confidence: None,
4325 runtime: None,
4326 matched_policy: None,
4327 };
4328 let label = effect_detail_reason(Some(&decl));
4329 assert!(
4331 label.starts_with("ok — verified by staticchecker"),
4332 "got: {label}"
4333 );
4334 }
4335
4336 #[test]
4337 fn effect_detail_none_and_empty() {
4338 assert_eq!(effect_detail_reason(None), "no effects declared");
4339 }
4340
4341 #[test]
4345 fn index_consistency_consistent_returns_null() {
4346 let v = compute_index_consistency(100, 100);
4347 assert!(v.is_null(), "consistent → Null; got: {v:#?}");
4348 }
4349
4350 #[test]
4351 fn index_consistency_asg_ahead_advises_reindex() {
4352 let v = compute_index_consistency(412, 408);
4356 assert_eq!(v["delta"], 4);
4357 assert_eq!(v["consistent"], false);
4358 let advice = v["advice"].as_str().expect("advice when divergent");
4359 assert!(advice.contains("4 symbols"), "got: {advice}");
4360 assert!(advice.contains("'asd index'"), "got: {advice}");
4361 }
4362
4363 #[test]
4364 fn index_consistency_singular_grammar() {
4365 let v = compute_index_consistency(101, 100);
4367 let advice = v["advice"].as_str().unwrap();
4368 assert!(advice.contains("1 symbol "), "got: {advice}");
4369 assert!(!advice.contains("1 symbols"), "got: {advice}");
4370 }
4371
4372 #[test]
4373 fn index_consistency_fts_ahead_advises_reindex_with_stale_wording() {
4374 let v = compute_index_consistency(100, 103);
4378 assert_eq!(v["delta"], -3);
4379 assert_eq!(v["consistent"], false);
4380 let advice = v["advice"].as_str().unwrap();
4381 assert!(advice.contains("3 stale symbols"), "got: {advice}");
4382 assert!(advice.contains("'asd index'"), "got: {advice}");
4383 }
4384
4385 #[test]
4386 fn index_consistency_handles_empty_repo() {
4387 let v = compute_index_consistency(0, 0);
4389 assert!(v.is_null());
4390 }
4391
4392 #[test]
4397 fn stale_classified_empty_index_is_critical() {
4398 let dir = tempfile::tempdir().unwrap();
4399 let db_path = dir.path().join("empty.db");
4400 let _ = SearchFtsDb::open(&db_path).unwrap();
4402 let w = stale_warning_classified(&db_path, 3600).expect("empty index must warn");
4403 assert_eq!(w.severity, StaleSeverity::Critical);
4404 assert!(w.message.contains("empty"), "got: {}", w.message);
4405 }
4406
4407 #[test]
4408 fn stale_classified_returns_none_when_no_fts_file() {
4409 let dir = tempfile::tempdir().unwrap();
4411 let db_path = dir.path().join("does_not_exist_yet.db");
4412 use crate::schema::{Position, Symbol, SymbolKind};
4419 let fts = SearchFtsDb::open(&db_path).unwrap();
4420 let sym = Symbol {
4421 symbol_id: "s1".into(),
4422 symbol_fp: "fp".into(),
4423 qname: "p.f".into(),
4424 language: "python".into(),
4425 kind: SymbolKind::Function,
4426 file: "p.py".into(),
4427 start: Position { line: 1, col: 0 },
4428 end: Position { line: 2, col: 0 },
4429 signature: None,
4430 doc: None,
4431 };
4432 fts.rebuild(&[sym]).unwrap();
4433 let now = std::time::SystemTime::now()
4435 .duration_since(std::time::UNIX_EPOCH)
4436 .unwrap()
4437 .as_secs() as i64;
4438 fts.conn
4439 .execute(
4440 "INSERT OR REPLACE INTO asd_index_meta(key, value) VALUES('indexed_at', ?1)",
4441 rusqlite::params![now.to_string()],
4442 )
4443 .unwrap();
4444 assert!(stale_warning_classified(&db_path, SOFT_STALE_THRESHOLD_SECS).is_none());
4446 }
4447
4448 #[test]
4449 fn stale_classified_soft_when_past_threshold_but_fts_healthy() {
4450 use crate::schema::{Position, Symbol, SymbolKind};
4451 let dir = tempfile::tempdir().unwrap();
4452 let db_path = dir.path().join("aged.db");
4453 let fts = SearchFtsDb::open(&db_path).unwrap();
4454 let sym = Symbol {
4455 symbol_id: "s1".into(),
4456 symbol_fp: "fp".into(),
4457 qname: "p.f".into(),
4458 language: "python".into(),
4459 kind: SymbolKind::Function,
4460 file: "p.py".into(),
4461 start: Position { line: 1, col: 0 },
4462 end: Position { line: 2, col: 0 },
4463 signature: None,
4464 doc: None,
4465 };
4466 fts.rebuild(&[sym]).unwrap();
4467 let now = std::time::SystemTime::now()
4469 .duration_since(std::time::UNIX_EPOCH)
4470 .unwrap()
4471 .as_secs() as i64;
4472 let two_days_ago = now - 48 * 3600;
4473 fts.conn
4474 .execute(
4475 "INSERT OR REPLACE INTO asd_index_meta(key, value) VALUES('indexed_at', ?1)",
4476 rusqlite::params![two_days_ago.to_string()],
4477 )
4478 .unwrap();
4479 let w = stale_warning_classified(&db_path, SOFT_STALE_THRESHOLD_SECS)
4480 .expect("48h-old at 24h threshold must warn");
4481 assert_eq!(w.severity, StaleSeverity::Soft);
4482 assert!(w.age_secs >= 48 * 3600);
4483 assert_eq!(w.indexed_at, Some(two_days_ago));
4484 }
4485
4486 #[test]
4487 fn stale_classified_severity_serializes_as_snake_case() {
4488 let critical = serde_json::to_value(StaleSeverity::Critical).unwrap();
4490 assert_eq!(critical, serde_json::Value::String("critical".to_string()));
4491 let soft = serde_json::to_value(StaleSeverity::Soft).unwrap();
4492 assert_eq!(soft, serde_json::Value::String("soft".to_string()));
4493 }
4494
4495 #[test]
4496 fn stopword_filtering() {
4497 let dir = tempfile::tempdir().unwrap();
4499 let db_path = dir.path().join("test.db");
4500 let fts = SearchFtsDb::open(&db_path).unwrap();
4501 use crate::schema::{Position, Symbol, SymbolKind};
4502 let sym = Symbol {
4503 symbol_id: "s1".into(),
4504 symbol_fp: "fp".into(),
4505 qname: "App.Foo.punchIn".into(),
4506 language: "swift".into(),
4507 kind: SymbolKind::Method,
4508 file: "App/Foo.swift".into(),
4509 start: Position { line: 1, col: 0 },
4510 end: Position { line: 5, col: 0 },
4511 signature: Some("func punchIn(over existingClip: Clip)".into()),
4512 doc: None,
4513 };
4514 fts.rebuild(std::slice::from_ref(&sym)).unwrap();
4515
4516 let hits = fts.search("over", &FtsFilters::default(), 10).unwrap();
4518 assert!(
4519 hits.is_empty(),
4520 "'over' is a stopword, should return no hits"
4521 );
4522
4523 let hits2 = fts
4526 .search("playhead over clips", &FtsFilters::default(), 10)
4527 .unwrap();
4528 assert!(
4529 hits2.is_empty(),
4530 "stopword-only overlap should not match punchIn"
4531 );
4532 }
4533
4534 #[test]
4535 fn split_camel_basic() {
4536 assert_eq!(
4537 split_camel("refreshDriftPlayhead"),
4538 vec!["refresh", "Drift", "Playhead"]
4539 );
4540 assert_eq!(
4541 split_camel("DriftSynthPool"),
4542 vec!["Drift", "Synth", "Pool"]
4543 );
4544 assert_eq!(split_camel("XMLParser"), vec!["XML", "Parser"]);
4545 assert_eq!(split_camel("simple"), vec!["simple"]);
4546 }
4547
4548 #[test]
4549 fn expand_identifier_roundtrip() {
4550 let exp = expand_identifier("App.AcmeFlow.refreshDriftPlayhead");
4551 assert!(exp.contains("refreshDriftPlayhead"), "original preserved");
4552 assert!(exp.contains("refresh"), "camel split");
4553 assert!(exp.contains("playhead"), "camel split tail");
4554 assert!(exp.contains("acmeflow"), "segment lowercased");
4555 }
4556
4557 #[test]
4558 fn is_test_file_detection() {
4559 assert!(is_test_file(
4560 "Packages/AudioEngine/Tests/KarplusStrongTests.swift"
4561 ));
4562 assert!(is_test_file("tests/test_charge_card.py"));
4563 assert!(is_test_file("src/__tests__/auth.test.ts"));
4564 assert!(is_test_file("payments/test_stripe.py"));
4565 assert!(is_test_file("pkg/payments/charge_test.go"));
4566 assert!(is_test_file("src/auth/auth_spec.rb"));
4567 assert!(!is_test_file("App/AcmeFlow/AcmeFlowApp.swift"));
4568 assert!(!is_test_file("src/payments/charge.py"));
4569 assert!(!is_test_file(
4570 "Packages/AudioEngine/Sources/KarplusStrong.swift"
4571 ));
4572 }
4573
4574 #[test]
4575 fn tier_classification() {
4576 assert_eq!(symbol_tier("App/ViewModel/DriftPlayheadViewModel.swift"), 0);
4578 assert_eq!(symbol_tier("src/payments/charge.py"), 0);
4579 assert_eq!(
4580 symbol_tier("Packages/AudioEngine/Sources/KarplusStrong.swift"),
4581 0
4582 );
4583
4584 assert_eq!(symbol_tier("App/Previews/DriftView_Previews.swift"), 1);
4586 assert_eq!(
4587 symbol_tier("App/ViewModel/DriftView_Previews.swift"),
4588 1,
4589 "preview filename"
4590 );
4591 assert_eq!(symbol_tier("Mocks/MockAudioEngine.swift"), 1);
4592 assert_eq!(symbol_tier("App/SampleData/TimelineFixture.swift"), 1);
4593 assert_eq!(symbol_tier("App/Generated/Schema.generated.swift"), 1);
4594
4595 assert_eq!(symbol_tier("Tests/DriftTests/PlayheadTests.swift"), 2);
4597 assert_eq!(symbol_tier("tests/test_charge.py"), 2);
4598 assert_eq!(symbol_tier("pkg/payments/charge_test.go"), 2);
4599 }
4600
4601 #[test]
4602 fn utility_symbols_rank_below_production() {
4603 let dir = tempfile::tempdir().unwrap();
4604 let db_path = dir.path().join("test.db");
4605 let fts = SearchFtsDb::open(&db_path).unwrap();
4606
4607 use crate::schema::{Position, Symbol, SymbolKind};
4608 let make = |id: &str, qname: &str, file: &str| Symbol {
4609 symbol_id: id.into(),
4610 symbol_fp: format!("fp_{id}"),
4611 qname: qname.into(),
4612 language: "swift".into(),
4613 kind: SymbolKind::Method,
4614 file: file.into(),
4615 start: Position { line: 1, col: 0 },
4616 end: Position { line: 5, col: 0 },
4617 signature: None,
4618 doc: None,
4619 };
4620
4621 let prod = make("prod", "App.VM.refreshDriftPlayhead", "App/ViewModel.swift");
4622 let preview = make(
4623 "preview",
4624 "App.Previews.refreshDriftPlayhead",
4625 "App/Previews/DriftView_Previews.swift",
4626 );
4627
4628 fts.rebuild(&[prod, preview]).unwrap();
4629
4630 let hits = fts
4631 .search("refresh drift playhead", &FtsFilters::default(), 10)
4632 .unwrap();
4633 assert_eq!(hits.len(), 2, "both prod and utility included by default");
4634 assert_eq!(hits[0].symbol_id, "prod", "production ranks before preview");
4636 assert_eq!(hits[0].tier, 0);
4637 assert_eq!(hits[1].tier, 1);
4638 }
4639
4640 #[test]
4641 fn fts_excludes_tests_by_default() {
4642 let dir = tempfile::tempdir().unwrap();
4643 let db_path = dir.path().join("test.db");
4644 let fts = SearchFtsDb::open(&db_path).unwrap();
4645
4646 use crate::schema::{Position, Symbol, SymbolKind};
4647 let make_sym = |id: &str, qname: &str, file: &str| Symbol {
4648 symbol_id: id.to_string(),
4649 symbol_fp: format!("fp_{id}"),
4650 qname: qname.to_string(),
4651 language: "swift".to_string(),
4652 kind: SymbolKind::Method,
4653 file: file.to_string(),
4654 start: Position { line: 1, col: 0 },
4655 end: Position { line: 10, col: 0 },
4656 signature: None,
4657 doc: None,
4658 };
4659
4660 let prod = make_sym(
4661 "sym_prod",
4662 "App.ViewModel.refreshDriftPlayhead",
4663 "App/ViewModel.swift",
4664 );
4665 let test = make_sym(
4666 "sym_test",
4667 "Tests.DriftTests.testRefreshPlayhead",
4668 "Tests/DriftTests.swift",
4669 );
4670
4671 fts.rebuild(&[prod, test]).unwrap();
4672 assert!(fts.has_data());
4673
4674 let hits = fts.search("playhead", &FtsFilters::default(), 10).unwrap();
4676 assert_eq!(hits.len(), 1, "only prod symbol by default");
4677 assert_eq!(hits[0].symbol_id, "sym_prod");
4678
4679 let hits_all = fts
4681 .search(
4682 "playhead",
4683 &FtsFilters {
4684 include_tests: true,
4685 ..Default::default()
4686 },
4687 10,
4688 )
4689 .unwrap();
4690 assert_eq!(hits_all.len(), 2, "both when include_tests");
4691
4692 let hits_test_only = fts
4694 .search(
4695 "playhead",
4696 &FtsFilters {
4697 tests_only: true,
4698 ..Default::default()
4699 },
4700 10,
4701 )
4702 .unwrap();
4703 assert_eq!(hits_test_only.len(), 1, "only test symbol when tests_only");
4704 assert_eq!(hits_test_only[0].symbol_id, "sym_test");
4705
4706 let hits_precedence = fts
4708 .search(
4709 "playhead",
4710 &FtsFilters {
4711 tests_only: true,
4712 include_tests: false,
4713 ..Default::default()
4714 },
4715 10,
4716 )
4717 .unwrap();
4718 assert_eq!(
4719 hits_precedence.len(),
4720 1,
4721 "tests_only overrides include_tests=false"
4722 );
4723 assert_eq!(hits_precedence[0].symbol_id, "sym_test");
4724 }
4725
4726 #[test]
4727 fn fts_round_trip() {
4728 let dir = tempfile::tempdir().unwrap();
4729 let db_path = dir.path().join("test.db");
4730 let fts = SearchFtsDb::open(&db_path).unwrap();
4731
4732 use crate::schema::{Position, Symbol, SymbolKind};
4733 let sym = Symbol {
4734 symbol_id: "sym_abc".into(),
4735 symbol_fp: "fp_abc".into(),
4736 qname: "App.ViewModel.refreshDriftPlayhead".into(),
4737 language: "swift".into(),
4738 kind: SymbolKind::Method,
4739 file: "App/ViewModel.swift".into(),
4740 start: Position { line: 10, col: 0 },
4741 end: Position { line: 20, col: 0 },
4742 signature: Some("private func refreshDriftPlayhead()".into()),
4743 doc: Some("Refreshes the drift playhead position".into()),
4744 };
4745
4746 fts.rebuild(std::slice::from_ref(&sym)).unwrap();
4747 assert!(fts.has_data());
4748
4749 let hits = fts.search("playhead", &FtsFilters::default(), 10).unwrap();
4750 assert!(!hits.is_empty(), "should find by qname fragment");
4751 assert_eq!(hits[0].symbol_id, "sym_abc");
4752 assert_eq!(
4753 hits[0].qname, "App.ViewModel.refreshDriftPlayhead",
4754 "orig qname preserved"
4755 );
4756 assert_eq!(hits[0].tier, 0, "production file should be tier 0");
4757
4758 let hits2 = fts
4759 .search("refresh drift", &FtsFilters::default(), 10)
4760 .unwrap();
4761 assert!(!hits2.is_empty(), "should find multi-token");
4762
4763 let hits3 = fts
4764 .search(
4765 "playhead",
4766 &FtsFilters {
4767 language: Some("python".into()),
4768 ..Default::default()
4769 },
4770 10,
4771 )
4772 .unwrap();
4773 assert!(hits3.is_empty(), "language filter should exclude swift");
4774 }
4775
4776 #[test]
4777 fn summary_extraction() {
4778 assert_eq!(
4780 extract_summary(
4781 Some(
4782 "Updates drift pad visual playhead from transport state. Called on every render tick."
4783 ),
4784 None
4785 ),
4786 "Updates drift pad visual playhead from transport state"
4787 );
4788 assert_eq!(
4790 extract_summary(Some("Short summary.\nMore detail here."), None),
4791 "Short summary"
4792 );
4793 assert_eq!(
4795 extract_summary(None, Some("func refreshDriftPlayhead() -> Void")),
4796 "func refreshDriftPlayhead() -> Void"
4797 );
4798 assert_eq!(extract_summary(None, None), "");
4800
4801 assert_eq!(
4803 extract_summary(
4804 Some("/// Updates the drift playhead. Called each frame."),
4805 None
4806 ),
4807 "Updates the drift playhead"
4808 );
4809 assert_eq!(
4811 extract_summary(
4812 Some("/// Refreshes visual state.\n/// Must be called on main thread."),
4813 None
4814 ),
4815 "Refreshes visual state"
4816 );
4817 assert_eq!(
4819 extract_summary(Some("/** Computes clip boundaries. */"), None),
4820 "Computes clip boundaries"
4821 );
4822 assert_eq!(
4824 extract_summary(Some("# Process the audio buffer."), None),
4825 "Process the audio buffer"
4826 );
4827 }
4828
4829 #[test]
4830 fn layer_classification() {
4831 assert_eq!(
4832 classify_layer("App/Views/DriftPlayheadView.swift", 0, &[]),
4833 "ui"
4834 );
4835 assert_eq!(
4836 classify_layer("App/ViewModel/DriftPlayheadViewModel.swift", 0, &[]),
4837 "viewmodel"
4838 );
4839 assert_eq!(
4840 classify_layer("App/Scheduler/DriftScheduler.swift", 0, &[]),
4841 "scheduler"
4842 );
4843 assert_eq!(
4844 classify_layer("App/Engine/AudioEngine.swift", 0, &[]),
4845 "scheduler"
4846 );
4847 assert_eq!(
4848 classify_layer("App/Storage/ClipRepository.swift", 0, &[]),
4849 "persistence"
4850 );
4851 assert_eq!(
4852 classify_layer("App/Domain/Clip.swift", 0, &[]),
4853 "core_model"
4854 );
4855 assert_eq!(
4856 classify_layer("App/Previews/DriftView_Previews.swift", 1, &[]),
4857 "utility"
4858 );
4859 assert_eq!(classify_layer("Tests/DriftTests.swift", 2, &[]), "tests");
4860 assert_eq!(classify_layer("App/AppDelegate.swift", 0, &[]), "other");
4861 }
4862
4863 #[test]
4864 fn layer_classification_qname_fallback() {
4865 assert_eq!(
4867 classify_layer_sym("App/AcmeFlow.swift", "AcmeFlowViewModel", 0, &[]),
4868 "viewmodel"
4869 );
4870 assert_eq!(
4871 classify_layer_sym("App/AcmeFlow.swift", "AcmeFlowController", 0, &[]),
4872 "viewmodel"
4873 );
4874 assert_eq!(
4875 classify_layer_sym("App/AcmeFlow.swift", "DriftCompiler", 0, &[]),
4876 "scheduler"
4877 );
4878 assert_eq!(
4879 classify_layer_sym("App/AcmeFlow.swift", "ClipStore", 0, &[]),
4880 "persistence"
4881 );
4882 assert_eq!(
4883 classify_layer_sym("App/AcmeFlow.swift", "DriftEngine", 0, &[]),
4884 "scheduler"
4885 );
4886 assert_eq!(
4888 classify_layer_sym("App/Views/DriftView.swift", "DriftViewModel", 0, &[]),
4889 "ui"
4890 );
4891 assert_eq!(
4893 classify_layer_sym("App/AppDelegate.swift", "AppDelegate", 0, &[]),
4894 "other"
4895 );
4896 assert_eq!(
4898 classify_layer_sym(
4899 "App/AcmeFlow.swift",
4900 "AcmeFlowViewModel.refreshDriftPlayhead",
4901 0,
4902 &[]
4903 ),
4904 "viewmodel"
4905 );
4906 assert_eq!(
4907 classify_layer_sym("App/AcmeFlow.swift", "DriftCompiler.compile", 0, &[]),
4908 "scheduler"
4909 );
4910 assert_eq!(
4911 classify_layer_sym("App/AcmeFlow.swift", "ClipStore.save", 0, &[]),
4912 "persistence"
4913 );
4914 assert_eq!(
4916 classify_layer_sym("src/drift.rs", "AcmeFlowViewModel::refresh", 0, &[]),
4917 "viewmodel"
4918 );
4919 }
4920
4921 #[test]
4922 fn layer_overrides_respected() {
4923 let overrides = vec![
4925 ("custominfra".to_string(), "persistence".to_string()),
4926 ("bizlogic".to_string(), "core_model".to_string()),
4927 ];
4928 assert_eq!(
4929 classify_layer("App/CustomInfra/Repo.swift", 0, &overrides),
4930 "persistence"
4931 );
4932 assert_eq!(
4933 classify_layer("App/BizLogic/Workflow.swift", 0, &overrides),
4934 "core_model"
4935 );
4936 assert_eq!(classify_layer("App/Views/Foo.swift", 0, &overrides), "ui");
4938 }
4939
4940 #[test]
4941 fn load_layer_overrides_missing_file() {
4942 let tmp = tempfile::tempdir().unwrap();
4943 let overrides = load_layer_overrides(&tmp.path().join("asd.db"));
4944 assert!(overrides.is_empty());
4945 }
4946
4947 #[test]
4948 fn load_layer_overrides_parses_toml() {
4949 let tmp = tempfile::tempdir().unwrap();
4950 let asd_dir = tmp.path().join(".asd");
4951 std::fs::create_dir_all(&asd_dir).unwrap();
4952 std::fs::write(
4953 asd_dir.join("layers.toml"),
4954 "[patterns]\nInfra = \"persistence\"\nBusiness = \"core_model\"\n",
4955 )
4956 .unwrap();
4957 let overrides = load_layer_overrides(&tmp.path().join("asd.db"));
4959 assert_eq!(overrides.len(), 2);
4960 assert!(overrides.contains(&("infra".to_string(), "persistence".to_string())));
4961 assert!(overrides.contains(&("business".to_string(), "core_model".to_string())));
4962 }
4963
4964 #[test]
4965 fn intent_parsing() {
4966 assert_eq!(parse_intent("bugfix"), Some("bugfix"));
4967 assert_eq!(parse_intent("ARCHITECTURE"), Some("architecture"));
4968 assert_eq!(parse_intent("ui"), Some("ui"));
4969 assert_eq!(parse_intent("typo"), None);
4970 for i in &[
4972 "bugfix",
4973 "feature",
4974 "refactor",
4975 "test",
4976 "architecture",
4977 "ui",
4978 ] {
4979 assert!(!intent_focus(i).is_empty(), "no focus for {i}");
4980 }
4981 for i in &[
4983 "bugfix",
4984 "feature",
4985 "refactor",
4986 "test",
4987 "architecture",
4988 "ui",
4989 "",
4990 ] {
4991 assert_eq!(intent_layer_order(i).len(), 8);
4992 }
4993 }
4994
4995 #[test]
4996 fn gather_recency_no_crash_outside_repo() {
4997 let result = gather_recency(10, 14.0);
5001 for (file, rec) in &result {
5003 assert!(!file.is_empty());
5004 if let Some(days) = rec.last_touched_days {
5005 assert!(days >= 0.0);
5006 }
5007 }
5008 }
5009
5010 #[test]
5013 fn symbols_with_constraint_penalties_via_sql_returns_matching_ids() {
5014 use crate::schema::{Author, AuthorKind, LedgerEntry, LedgerKind};
5015 let dir = tempfile::tempdir().unwrap();
5016 let db_path = dir.path().join("test.db");
5017 let fts = SearchFtsDb::open(&db_path).unwrap();
5018
5019 let make = |sym_id: &str, kind: LedgerKind, role: Option<&str>| {
5026 let mut e = LedgerEntry::new(
5027 sym_id,
5028 kind,
5029 "test entry",
5030 Author {
5031 kind: AuthorKind::Agent,
5032 id: "t".into(),
5033 },
5034 );
5035 e.role = role.map(str::to_string);
5036 e
5037 };
5038 for (sym_id, kind, role) in [
5039 ("sym_a", LedgerKind::Constraint, Some("stale-api")),
5040 ("sym_b", LedgerKind::Decision, Some("audit-pending")),
5041 ("sym_c", LedgerKind::Hazard, Some("stale-api")),
5042 ("sym_d", LedgerKind::Constraint, Some("fast-test")),
5043 ("sym_e", LedgerKind::Constraint, None),
5044 ] {
5045 fts.upsert_ledger_entry(&make(sym_id, kind, role), "main")
5046 .unwrap();
5047 }
5048
5049 let mut ids = fts.symbols_with_constraint_penalties("main").unwrap();
5050 ids.sort();
5051 assert_eq!(ids, vec!["sym_a".to_string(), "sym_b".to_string()]);
5052 }
5053
5054 #[test]
5055 fn symbols_with_constraint_penalties_returns_empty_when_cache_empty() {
5056 let dir = tempfile::tempdir().unwrap();
5057 let db_path = dir.path().join("test.db");
5058 let fts = SearchFtsDb::open(&db_path).unwrap();
5059 let ids = fts.symbols_with_constraint_penalties("main").unwrap();
5060 assert!(ids.is_empty());
5061 }
5062}
5063
5064#[cfg(test)]
5065mod plan_j_t007_test_stub_tests {
5066 use super::propose_test_stub;
5071
5072 fn assert_contains_all(actual: &str, needles: &[&str]) {
5073 for n in needles {
5074 assert!(actual.contains(n), "stub missing `{n}` — got:\n{actual}");
5075 }
5076 }
5077
5078 #[test]
5079 fn python_stub_uses_snake_case_def_and_raises_until_filled() {
5080 let s = propose_test_stub("src/billing/calc.py", "discount");
5081 assert_contains_all(
5082 &s,
5083 &[
5084 "def test_discount",
5085 "arrange",
5086 "act",
5087 "assert",
5088 "NotImplementedError",
5089 ],
5090 );
5091 }
5092
5093 #[test]
5094 fn typescript_stub_uses_test_function_and_throws() {
5095 let s = propose_test_stub("src/billing/calc.ts", "discount");
5096 assert_contains_all(&s, &["test('discount'", "throw new Error"]);
5097 }
5098
5099 #[test]
5100 fn rust_stub_uses_test_attribute_and_todo_macro() {
5101 let s = propose_test_stub("src/billing/calc.rs", "discount");
5102 assert_contains_all(&s, &["#[test]", "fn discount", "todo!"]);
5103 }
5104
5105 #[test]
5106 fn go_stub_uses_pascal_case_and_t_fatal() {
5107 let s = propose_test_stub("billing/calc.go", "discount");
5108 assert_contains_all(&s, &["func TestDiscount", "*testing.T", "t.Fatal"]);
5109 }
5110
5111 #[test]
5112 fn java_stub_uses_test_annotation_and_fail() {
5113 let s = propose_test_stub("Billing.java", "discount");
5114 assert_contains_all(&s, &["@Test", "testDiscount", "fail("]);
5115 }
5116
5117 #[test]
5118 fn csharp_stub_uses_pascal_case_and_assert_fail() {
5119 let s = propose_test_stub("Billing.cs", "discount");
5120 assert_contains_all(&s, &["[Test]", "Discount_Should", "Assert.Fail"]);
5121 }
5122
5123 #[test]
5124 fn ruby_stub_uses_def_test_and_flunk() {
5125 let s = propose_test_stub("billing.rb", "discount");
5126 assert_contains_all(&s, &["def test_discount", "flunk"]);
5127 }
5128
5129 #[test]
5130 fn kotlin_stub_uses_test_annotation_and_backticks() {
5131 let s = propose_test_stub("Billing.kt", "discount");
5132 assert_contains_all(&s, &["@Test", "`discount`", "fail("]);
5133 }
5134
5135 #[test]
5136 fn swift_stub_uses_pascal_case_and_xctfail() {
5137 let s = propose_test_stub("Billing.swift", "discount");
5138 assert_contains_all(&s, &["func testDiscount", "XCTFail"]);
5139 }
5140
5141 #[test]
5142 fn unknown_extension_falls_back_to_generic_comment() {
5143 let s = propose_test_stub("data.bin", "discount");
5144 assert!(s.contains("discount"));
5145 assert!(s.starts_with("// "));
5146 }
5147
5148 #[test]
5149 fn qname_prefix_is_stripped_from_test_name() {
5150 let s = propose_test_stub("src/calc.py", "billing.payment.charge");
5152 assert!(s.contains("def test_charge"), "got:\n{s}");
5153 assert!(
5154 !s.contains("billing_payment"),
5155 "qname module path must not leak into test name; got:\n{s}"
5156 );
5157 }
5158
5159 #[test]
5160 fn camel_case_input_becomes_snake_for_python() {
5161 let s = propose_test_stub("src/calc.py", "applyDiscount");
5162 assert!(s.contains("def test_apply_discount"), "got:\n{s}");
5163 }
5164}
5165
5166#[cfg(test)]
5167mod plan_j_t003_classify_file_role_tests {
5168 use super::classify_file_role;
5174
5175 #[test]
5176 fn impl_is_default_when_nothing_matches() {
5177 assert_eq!(classify_file_role("src/lib.rs"), "impl");
5178 assert_eq!(classify_file_role("crates/foo/src/main.py"), "impl");
5179 }
5180
5181 #[test]
5182 fn tests_short_circuit_before_view_check() {
5183 assert_eq!(
5186 classify_file_role("App/Tests/AcmeFlowViewTests.swift"),
5187 "test"
5188 );
5189 assert_eq!(classify_file_role("src/lib_test.py"), "test");
5190 assert_eq!(classify_file_role("crates/x/tests/it.rs"), "test");
5191 }
5192
5193 #[test]
5194 fn viewmodel_by_filename_suffix() {
5195 assert_eq!(
5197 classify_file_role("App/Sources/AcmeFlowViewModel.swift"),
5198 "viewmodel"
5199 );
5200 assert_eq!(
5201 classify_file_role("web/src/PlaybackViewModel.ts"),
5202 "viewmodel"
5203 );
5204 }
5205
5206 #[test]
5207 fn viewmodel_by_path_pattern() {
5208 assert_eq!(
5209 classify_file_role("App/Sources/viewmodels/AcmeFlow.swift"),
5210 "viewmodel"
5211 );
5212 assert_eq!(classify_file_role("web/viewmodel/foo.ts"), "viewmodel");
5213 }
5214
5215 #[test]
5216 fn view_by_filename_suffix() {
5217 assert_eq!(classify_file_role("App/Sources/AcmeFlowView.swift"), "view");
5218 assert_eq!(classify_file_role("web/src/TrackView.tsx"), "view");
5219 }
5220
5221 #[test]
5222 fn view_by_path_pattern() {
5223 assert_eq!(
5224 classify_file_role("App/Sources/views/AcmeFlow.swift"),
5225 "view"
5226 );
5227 assert_eq!(classify_file_role("web/view/index.ts"), "view");
5228 }
5229
5230 #[test]
5231 fn view_by_extension() {
5232 assert_eq!(classify_file_role("web/src/App.vue"), "view");
5233 assert_eq!(classify_file_role("web/src/App.svelte"), "view");
5234 }
5235
5236 #[test]
5237 fn viewmodel_short_circuits_before_view() {
5238 let r = classify_file_role("App/Sources/AcmeFlowViewModel.swift");
5243 assert_eq!(r, "viewmodel", "ViewModel must NOT fall to `view`; got {r}");
5244 }
5245
5246 #[test]
5247 fn preview_does_not_match_view_in_middle_of_segment() {
5248 assert_eq!(
5255 classify_file_role("src/PreviewService.swift"),
5256 "impl",
5257 "PreviewService.swift must NOT be a view"
5258 );
5259 }
5260
5261 #[test]
5262 fn existing_roles_still_classify_correctly() {
5263 assert_eq!(
5266 classify_file_role("examples/sample-py-repo/foo.py"),
5267 "example"
5268 );
5269 assert_eq!(classify_file_role("fixtures/seed.json"), "fixture");
5270 assert_eq!(classify_file_role("scripts/release.sh"), "script");
5271 assert_eq!(classify_file_role("generated/proto.pb.go"), "generated");
5272 assert_eq!(classify_file_role("docs/architecture.md"), "reference");
5273 }
5274}