Skip to main content

code_kb_core/
telemetry.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::RwLock;
4use std::time::SystemTime;
5
6use rusqlite::{Connection, params};
7use serde::{Deserialize, Serialize};
8
9use crate::queries::QueryError;
10use crate::workspace::{normalize_path, to_forward_slash};
11
12#[derive(Debug, Clone)]
13pub struct ToolInvocation<'a> {
14    pub tool: &'a str,
15    pub duration_ms: u64,
16    pub outcome: &'a str, // "ok", "empty", "error"
17    pub error_message: Option<&'a str>,
18    pub logical_result_count: Option<usize>,
19    pub bytes_returned: usize,
20    pub est_tokens: usize,
21    pub est_tokens_saved: usize,
22    /// True when the answer had files to measure against, so `est_tokens_saved` is a real figure.
23    pub est_tokens_saved_known: bool,
24    pub reconcile_ms: Option<u64>,
25    pub query_ms: Option<u64>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
29pub enum TimeWindow {
30    Today,
31    Last7Days,
32    Last30Days,
33    ThisMonth,
34    LastYear,
35    #[default]
36    AllTime,
37}
38
39impl TimeWindow {
40    pub fn parse(s: &str) -> Option<Self> {
41        match s.to_lowercase().as_str() {
42            "today" => Some(Self::Today),
43            "7d" | "week" => Some(Self::Last7Days),
44            "30d" => Some(Self::Last30Days),
45            "month" | "this-month" => Some(Self::ThisMonth),
46            "year" | "last-year" => Some(Self::LastYear),
47            "all" | "all-time" => Some(Self::AllTime),
48            _ => None,
49        }
50    }
51
52    pub fn to_sqlite_condition(&self) -> Option<&'static str> {
53        match self {
54            Self::Today => Some("timestamp >= datetime('now', 'localtime', 'start of day')"),
55            Self::Last7Days => Some("timestamp >= datetime('now', '-7 days')"),
56            Self::Last30Days => Some("timestamp >= datetime('now', '-30 days')"),
57            Self::ThisMonth => Some("timestamp >= datetime('now', 'localtime', 'start of month')"),
58            Self::LastYear => Some("timestamp >= datetime('now', '-365 days')"),
59            Self::AllTime => None,
60        }
61    }
62}
63
64impl std::fmt::Display for TimeWindow {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::Today => write!(f, "today"),
68            Self::Last7Days => write!(f, "7d"),
69            Self::Last30Days => write!(f, "30d"),
70            Self::ThisMonth => write!(f, "month"),
71            Self::LastYear => write!(f, "year"),
72            Self::AllTime => write!(f, "all"),
73        }
74    }
75}
76
77#[derive(Debug, Clone, Default, Serialize, Deserialize)]
78pub struct TelemetryFilter {
79    pub time_window: TimeWindow,
80    pub workspace_root: Option<PathBuf>,
81    pub version: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct TelemetrySummary {
86    pub total_calls: usize,
87    pub ok_calls: usize,
88    pub empty_calls: usize,
89    pub error_calls: usize,
90    pub total_tokens_returned: usize,
91    pub est_tokens_saved: usize,
92    pub saved_known_calls: usize,
93    pub time_window: TimeWindow,
94    pub scope_description: String,
95    pub tool_stats: Vec<ToolStat>,
96    pub recent_errors: Vec<TelemetryErrorRecord>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ToolStat {
101    pub tool: String,
102    pub count: usize,
103    pub ok_count: usize,
104    pub empty_count: usize,
105    pub error_count: usize,
106    pub avg_duration_ms: u64,
107    pub p50_ms: Option<u64>,
108    pub p95_ms: Option<u64>,
109    pub avg_reconcile_ms: Option<u64>,
110    pub avg_query_ms: Option<u64>,
111    pub tokens_returned: usize,
112    pub tokens_saved: usize,
113    pub saved_known_count: usize,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct TelemetryErrorRecord {
118    pub timestamp: String,
119    pub tool: String,
120    pub error_message: String,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct IndexFacts {
125    pub extractor_version: Option<String>,
126    pub schema_version: Option<String>,
127    pub index_level: Option<String>,
128    pub updated_at: Option<String>,
129    pub file_count: i64,
130    pub symbol_count: i64,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct BugReportBundle {
135    pub os_info: String,
136    pub arch_info: String,
137    pub code_kb_version: String,
138    pub julie_extract_version: String,
139    pub active_workspace_name: Option<String>,
140    pub index: Option<IndexFacts>,
141    pub recent_errors: Vec<TelemetryErrorRecord>,
142    pub log_tail: Vec<String>,
143    pub markdown_body: String,
144    pub github_issue_url: String,
145}
146
147static TELEMETRY_DIR_OVERRIDE: RwLock<Option<PathBuf>> = RwLock::new(None);
148
149pub fn resolve_telemetry_dir(
150    env_dir: Option<String>,
151    cargo_target_tmp: Option<String>,
152    home: Option<String>,
153    userprofile: Option<String>,
154) -> PathBuf {
155    if let Some(dir) = env_dir
156        && !dir.trim().is_empty()
157    {
158        return PathBuf::from(dir);
159    }
160    if let Some(target_tmp) = cargo_target_tmp
161        && !target_tmp.trim().is_empty()
162    {
163        return PathBuf::from(target_tmp).join("test-telemetry");
164    }
165    if let Some(home) = home
166        && !home.trim().is_empty()
167    {
168        return PathBuf::from(home).join(".code-kb");
169    }
170    if let Some(profile) = userprofile
171        && !profile.trim().is_empty()
172    {
173        return PathBuf::from(profile).join(".code-kb");
174    }
175    PathBuf::from(".code-kb")
176}
177
178pub fn is_telemetry_disabled_with(no_telem: Option<&str>, disable_telem: Option<&str>) -> bool {
179    no_telem
180        .or(disable_telem)
181        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
182        .unwrap_or(false)
183}
184
185pub fn is_telemetry_disabled() -> bool {
186    let no_telem = std::env::var("CODE_KB_NO_TELEMETRY").ok();
187    let disable_telem = std::env::var("CODE_KB_DISABLE_TELEMETRY").ok();
188    is_telemetry_disabled_with(no_telem.as_deref(), disable_telem.as_deref())
189}
190
191pub fn get_global_telemetry_dir() -> PathBuf {
192    if let Ok(guard) = TELEMETRY_DIR_OVERRIDE.read()
193        && let Some(ref path) = *guard
194    {
195        return path.clone();
196    }
197    resolve_telemetry_dir(
198        std::env::var("CODE_KB_TELEMETRY_DIR").ok(),
199        std::env::var("CARGO_TARGET_TMPDIR").ok(),
200        std::env::var("HOME").ok(),
201        std::env::var("USERPROFILE").ok(),
202    )
203}
204
205#[cfg(test)]
206pub(crate) fn set_telemetry_dir_override(path: Option<PathBuf>) {
207    if let Ok(mut guard) = TELEMETRY_DIR_OVERRIDE.write() {
208        *guard = path;
209    }
210}
211
212pub fn open_global_telemetry_db() -> Result<Connection, QueryError> {
213    open_telemetry_db_at(&get_global_telemetry_dir())
214}
215
216pub fn open_telemetry_db_at(dir: &Path) -> Result<Connection, QueryError> {
217    if !dir.exists() {
218        let _ = std::fs::create_dir_all(dir);
219    }
220    let db_path = dir.join("telemetry.db");
221    let conn = Connection::open(&db_path)?;
222    init_telemetry_db(&conn)?;
223    Ok(conn)
224}
225
226fn init_telemetry_db(conn: &Connection) -> Result<(), QueryError> {
227    conn.execute_batch(
228        "PRAGMA journal_mode = WAL;
229         PRAGMA synchronous = NORMAL;
230         PRAGMA busy_timeout = 5000;
231         CREATE TABLE IF NOT EXISTS tool_telemetry (
232             id TEXT PRIMARY KEY,
233             timestamp TEXT NOT NULL,
234             workspace_root TEXT NOT NULL,
235             workspace_name TEXT NOT NULL,
236             tool TEXT NOT NULL,
237             duration_ms INTEGER NOT NULL,
238             outcome TEXT NOT NULL,
239             error_message TEXT,
240             result_count INTEGER NOT NULL DEFAULT 0,
241             result_count_known INTEGER NOT NULL DEFAULT 0,
242             bytes_returned INTEGER NOT NULL DEFAULT 0,
243             est_tokens INTEGER NOT NULL DEFAULT 0,
244             est_tokens_saved INTEGER NOT NULL DEFAULT 0,
245             est_tokens_saved_known INTEGER NOT NULL DEFAULT 0,
246             code_kb_version TEXT NOT NULL,
247             reconcile_ms INTEGER DEFAULT NULL,
248             query_ms INTEGER DEFAULT NULL
249         );
250         CREATE INDEX IF NOT EXISTS idx_tool_telemetry_tool ON tool_telemetry(tool, timestamp DESC);
251         CREATE INDEX IF NOT EXISTS idx_tool_telemetry_ts ON tool_telemetry(timestamp DESC);
252         DELETE FROM tool_telemetry WHERE timestamp < datetime('now', '-365 days');",
253    )?;
254
255    // Handle column migrations if table previously existed without workspace columns
256    let mut stmt = conn.prepare("PRAGMA table_info(tool_telemetry)")?;
257    let cols = stmt.query_map([], |row| row.get::<_, String>(1))?;
258    let mut col_names = HashSet::new();
259    for col in cols.flatten() {
260        col_names.insert(col);
261    }
262    if !col_names.contains("workspace_root") {
263        conn.execute(
264            "ALTER TABLE tool_telemetry ADD COLUMN workspace_root TEXT NOT NULL DEFAULT ''",
265            [],
266        )?;
267    }
268    if !col_names.contains("workspace_name") {
269        conn.execute(
270            "ALTER TABLE tool_telemetry ADD COLUMN workspace_name TEXT NOT NULL DEFAULT ''",
271            [],
272        )?;
273    }
274    if !col_names.contains("est_tokens_saved") {
275        conn.execute(
276            "ALTER TABLE tool_telemetry ADD COLUMN est_tokens_saved INTEGER NOT NULL DEFAULT 0",
277            [],
278        )?;
279    }
280    if !col_names.contains("est_tokens_saved_known") {
281        conn.execute(
282            "ALTER TABLE tool_telemetry ADD COLUMN est_tokens_saved_known INTEGER NOT NULL DEFAULT 0",
283            [],
284        )?;
285    }
286    if !col_names.contains("result_count_known") {
287        conn.execute(
288            "ALTER TABLE tool_telemetry ADD COLUMN result_count_known INTEGER NOT NULL DEFAULT 0",
289            [],
290        )?;
291    }
292    if !col_names.contains("reconcile_ms") {
293        conn.execute(
294            "ALTER TABLE tool_telemetry ADD COLUMN reconcile_ms INTEGER DEFAULT NULL",
295            [],
296        )?;
297    }
298    if !col_names.contains("query_ms") {
299        conn.execute(
300            "ALTER TABLE tool_telemetry ADD COLUMN query_ms INTEGER DEFAULT NULL",
301            [],
302        )?;
303    }
304    if col_names.contains("version") && !col_names.contains("code_kb_version") {
305        conn.execute(
306            "ALTER TABLE tool_telemetry RENAME COLUMN version TO code_kb_version",
307            [],
308        )?;
309    } else if !col_names.contains("code_kb_version") {
310        conn.execute(
311            "ALTER TABLE tool_telemetry ADD COLUMN code_kb_version TEXT NOT NULL DEFAULT ''",
312            [],
313        )?;
314    }
315
316    // Dependent indexes must be created AFTER columns are verified to exist
317    conn.execute(
318        "CREATE INDEX IF NOT EXISTS idx_tool_telemetry_ws_ts ON tool_telemetry(workspace_root, timestamp DESC)",
319        [],
320    )?;
321
322    Ok(())
323}
324
325/// Open the global telemetry database, delegating to `open_global_telemetry_db()`.
326pub fn open_telemetry_db(_workspace_root: &Path) -> Result<Connection, QueryError> {
327    open_global_telemetry_db()
328}
329
330/// Returns the normalized workspace root and an optional alternate representation
331/// (e.g. resolving canonical path, or mapping macOS `/private/var`, `/private/tmp`, `/private/etc` symmetry).
332pub(crate) fn workspace_root_match_candidates(ws: &Path) -> (String, Option<String>) {
333    let norm = to_forward_slash(&normalize_path(ws));
334    let canonical = dunce::canonicalize(ws)
335        .map(|p| to_forward_slash(&normalize_path(&p)))
336        .unwrap_or_else(|_| norm.clone());
337
338    if canonical != norm {
339        return (canonical, Some(norm));
340    }
341
342    // Handle macOS `/private/var`, `/private/tmp`, `/private/etc` symmetry
343    if let Some(rest) = norm.strip_prefix("/private/") {
344        if rest.starts_with("var/") || rest.starts_with("tmp/") || rest.starts_with("etc/") {
345            return (norm.clone(), Some(format!("/{}", rest)));
346        }
347    } else if norm.starts_with("/var/") || norm.starts_with("/tmp/") || norm.starts_with("/etc/") {
348        return (norm.clone(), Some(format!("/private{}", norm)));
349    }
350
351    (norm, None)
352}
353
354/// Fast record of a tool invocation with normalized workspace path attribution.
355pub fn record_tool_call_conn(
356    conn: &Connection,
357    workspace_root: &Path,
358    invocation: &ToolInvocation,
359) {
360    if is_telemetry_disabled() {
361        return;
362    }
363    let now = SystemTime::now()
364        .duration_since(SystemTime::UNIX_EPOCH)
365        .unwrap_or_default();
366    let ts = format!("{:?}", SystemTime::now());
367    let id_source = format!("{}:{}:{}", invocation.tool, ts, now.as_nanos());
368    let id = blake3::hash(id_source.as_bytes()).to_hex().to_string();
369    let version = env!("CARGO_PKG_VERSION");
370
371    let canonical =
372        dunce::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
373    let norm_ws = to_forward_slash(&normalize_path(&canonical));
374    let ws_name = Path::new(&norm_ws)
375        .file_name()
376        .map(|n| n.to_string_lossy().to_string())
377        .unwrap_or_else(|| "repo".to_string());
378
379    let _ = conn.execute(
380        "INSERT INTO tool_telemetry (
381            id, timestamp, workspace_root, workspace_name, tool,
382            duration_ms, outcome, error_message, result_count, result_count_known,
383            bytes_returned, est_tokens, est_tokens_saved, est_tokens_saved_known,
384            code_kb_version, reconcile_ms, query_ms
385        ) VALUES (?1, datetime('now'), ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
386        params![
387            id,
388            norm_ws,
389            ws_name,
390            invocation.tool,
391            invocation.duration_ms as i64,
392            invocation.outcome,
393            invocation.error_message,
394            invocation.logical_result_count.unwrap_or_default() as i64,
395            invocation.logical_result_count.is_some() as i64,
396            invocation.bytes_returned as i64,
397            invocation.est_tokens as i64,
398            invocation.est_tokens_saved as i64,
399            invocation.est_tokens_saved_known as i64,
400            version,
401            invocation.reconcile_ms.map(|v| v as i64),
402            invocation.query_ms.map(|v| v as i64),
403        ],
404    );
405}
406
407/// Record a tool call to the global telemetry database.
408/// Best-effort and non-panicking.
409pub fn record_tool_call(workspace_root: &Path, invocation: &ToolInvocation) {
410    if is_telemetry_disabled() {
411        return;
412    }
413    if let Ok(conn) = open_global_telemetry_db() {
414        record_tool_call_conn(&conn, workspace_root, invocation);
415    }
416}
417
418fn calculate_percentile(sorted: &[u64], pct: f64) -> Option<u64> {
419    if sorted.is_empty() {
420        return None;
421    }
422    let rank = ((pct / 100.0) * sorted.len() as f64).ceil() as usize;
423    let idx = rank.saturating_sub(1).min(sorted.len() - 1);
424    Some(sorted[idx])
425}
426
427pub fn get_telemetry_summary(
428    conn: &Connection,
429    filter: &TelemetryFilter,
430) -> Result<TelemetrySummary, QueryError> {
431    let mut conditions = Vec::new();
432    let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
433
434    if let Some(time_cond) = filter.time_window.to_sqlite_condition() {
435        conditions.push(time_cond.to_string());
436    }
437
438    if let Some(ref ws) = filter.workspace_root {
439        let (canon, alt) = workspace_root_match_candidates(ws);
440        if let Some(alt_val) = alt {
441            let idx1 = params_vec.len() + 1;
442            let idx2 = params_vec.len() + 2;
443            conditions.push(format!(
444                "(workspace_root = ?{} OR workspace_root = ?{})",
445                idx1, idx2
446            ));
447            params_vec.push(Box::new(canon));
448            params_vec.push(Box::new(alt_val));
449        } else {
450            let idx1 = params_vec.len() + 1;
451            conditions.push(format!("workspace_root = ?{}", idx1));
452            params_vec.push(Box::new(canon));
453        }
454    }
455
456    if let Some(ref v) = filter.version {
457        let idx = params_vec.len() + 1;
458        conditions.push(format!("code_kb_version = ?{}", idx));
459        params_vec.push(Box::new(v.clone()));
460    }
461
462    let where_clause = if conditions.is_empty() {
463        String::new()
464    } else {
465        format!("WHERE {}", conditions.join(" AND "))
466    };
467
468    let ws_desc = match &filter.workspace_root {
469        Some(ws) => format!("Workspace: {}", to_forward_slash(&normalize_path(ws))),
470        None => "Global (all workspaces)".to_string(),
471    };
472    let ver_desc = match &filter.version {
473        Some(v) => format!("Version: {}", v),
474        None => "Version: all".to_string(),
475    };
476    let scope_description = format!("{} | {}", ws_desc, ver_desc);
477
478    let params_slice: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
479
480    // 1. Overall aggregations
481    let totals_sql = format!(
482        "SELECT COUNT(*),
483                SUM(CASE WHEN outcome = 'ok' THEN 1 ELSE 0 END),
484                SUM(CASE WHEN outcome = 'empty' THEN 1 ELSE 0 END),
485                SUM(CASE WHEN outcome = 'error' THEN 1 ELSE 0 END),
486                SUM(est_tokens),
487                SUM(est_tokens_saved),
488                SUM(CASE WHEN est_tokens_saved_known = 1 THEN 1 ELSE 0 END)
489         FROM tool_telemetry
490         {}",
491        where_clause
492    );
493
494    let (
495        total_calls,
496        ok_calls,
497        empty_calls,
498        error_calls,
499        total_tokens_returned,
500        est_tokens_saved,
501        saved_known_calls,
502    ) = {
503        let mut stmt = conn.prepare(&totals_sql)?;
504        let row_mapper = |row: &rusqlite::Row| {
505            let total: i64 = row.get(0)?;
506            let ok: Option<i64> = row.get(1)?;
507            let empty: Option<i64> = row.get(2)?;
508            let error: Option<i64> = row.get(3)?;
509            let tokens: Option<i64> = row.get(4)?;
510            let tokens_saved: Option<i64> = row.get(5)?;
511            let saved_known: Option<i64> = row.get(6)?;
512            Ok((
513                total as usize,
514                ok.unwrap_or(0) as usize,
515                empty.unwrap_or(0) as usize,
516                error.unwrap_or(0) as usize,
517                tokens.unwrap_or(0) as usize,
518                tokens_saved.unwrap_or(0) as usize,
519                saved_known.unwrap_or(0) as usize,
520            ))
521        };
522        stmt.query_row(
523            rusqlite::params_from_iter(params_slice.iter().copied()),
524            row_mapper,
525        )?
526    };
527
528    // 2. Collect durations for percentiles
529    let mut durations_by_tool: HashMap<String, Vec<u64>> = HashMap::new();
530    {
531        let durations_sql = format!(
532            "SELECT tool, duration_ms
533             FROM tool_telemetry
534             {}
535             ORDER BY tool, duration_ms ASC",
536            where_clause
537        );
538        let mut d_stmt = conn.prepare(&durations_sql)?;
539        let d_rows = d_stmt.query_map(
540            rusqlite::params_from_iter(params_slice.iter().copied()),
541            |row| {
542                let tool: String = row.get(0)?;
543                let dur: i64 = row.get(1)?;
544                Ok((tool, dur.max(0) as u64))
545            },
546        )?;
547        for item in d_rows.flatten() {
548            durations_by_tool.entry(item.0).or_default().push(item.1);
549        }
550    }
551
552    // 3. Per-tool statistics
553    let tool_stats_sql = format!(
554        "SELECT tool,
555                COUNT(*),
556                SUM(CASE WHEN outcome = 'ok' THEN 1 ELSE 0 END),
557                SUM(CASE WHEN outcome = 'empty' THEN 1 ELSE 0 END),
558                SUM(CASE WHEN outcome = 'error' THEN 1 ELSE 0 END),
559                ROUND(AVG(duration_ms)),
560                SUM(est_tokens),
561                SUM(est_tokens_saved),
562                SUM(CASE WHEN est_tokens_saved_known = 1 THEN 1 ELSE 0 END),
563                ROUND(AVG(reconcile_ms)),
564                ROUND(AVG(query_ms))
565         FROM tool_telemetry
566         {}
567         GROUP BY tool
568         ORDER BY COUNT(*) DESC",
569        where_clause
570    );
571
572    let mut tool_stats = Vec::new();
573    {
574        let mut stmt = conn.prepare(&tool_stats_sql)?;
575        let row_mapper = |row: &rusqlite::Row| {
576            let tool: String = row.get(0)?;
577            let count: i64 = row.get(1)?;
578            let ok_count: Option<i64> = row.get(2)?;
579            let empty_count: Option<i64> = row.get(3)?;
580            let error_count: Option<i64> = row.get(4)?;
581            let avg_duration: Option<f64> = row.get(5)?;
582            let tokens: Option<i64> = row.get(6)?;
583            let tokens_saved: Option<i64> = row.get(7)?;
584            let saved_known: Option<i64> = row.get(8)?;
585            let avg_rec: Option<f64> = row.get(9)?;
586            let avg_q: Option<f64> = row.get(10)?;
587
588            let (p50, p95) = if let Some(durs) = durations_by_tool.get(&tool) {
589                (
590                    calculate_percentile(durs, 50.0),
591                    calculate_percentile(durs, 95.0),
592                )
593            } else {
594                (None, None)
595            };
596
597            Ok(ToolStat {
598                tool,
599                count: count as usize,
600                ok_count: ok_count.unwrap_or(0) as usize,
601                empty_count: empty_count.unwrap_or(0) as usize,
602                error_count: error_count.unwrap_or(0) as usize,
603                avg_duration_ms: avg_duration.unwrap_or(0.0).round() as u64,
604                p50_ms: p50,
605                p95_ms: p95,
606                avg_reconcile_ms: avg_rec.map(|v| v.round() as u64),
607                avg_query_ms: avg_q.map(|v| v.round() as u64),
608                tokens_returned: tokens.unwrap_or(0) as usize,
609                tokens_saved: tokens_saved.unwrap_or(0) as usize,
610                saved_known_count: saved_known.unwrap_or(0) as usize,
611            })
612        };
613
614        let rows = stmt.query_map(
615            rusqlite::params_from_iter(params_slice.iter().copied()),
616            row_mapper,
617        )?;
618        for stat in rows.flatten() {
619            tool_stats.push(stat);
620        }
621    }
622
623    // 4. Recent errors
624    let error_where_clause = if where_clause.is_empty() {
625        "WHERE outcome = 'error' AND error_message IS NOT NULL".to_string()
626    } else {
627        format!(
628            "{} AND outcome = 'error' AND error_message IS NOT NULL",
629            where_clause
630        )
631    };
632
633    let errors_sql = format!(
634        "SELECT timestamp, tool, error_message
635         FROM tool_telemetry
636         {}
637         ORDER BY timestamp DESC
638         LIMIT 10",
639        error_where_clause
640    );
641
642    let mut recent_errors = Vec::new();
643    {
644        let mut stmt = conn.prepare(&errors_sql)?;
645        let row_mapper = |row: &rusqlite::Row| {
646            let raw_msg: String = row.get(2)?;
647            Ok(TelemetryErrorRecord {
648                timestamp: row.get(0)?,
649                tool: row.get(1)?,
650                error_message: sanitize_error_message(&raw_msg),
651            })
652        };
653
654        let rows = stmt.query_map(
655            rusqlite::params_from_iter(params_slice.iter().copied()),
656            row_mapper,
657        )?;
658        for err in rows.flatten() {
659            recent_errors.push(err);
660        }
661    }
662
663    Ok(TelemetrySummary {
664        total_calls,
665        ok_calls,
666        empty_calls,
667        error_calls,
668        total_tokens_returned,
669        est_tokens_saved,
670        saved_known_calls,
671        time_window: filter.time_window,
672        scope_description,
673        tool_stats,
674        recent_errors,
675    })
676}
677
678fn sanitize_error_message(msg: &str) -> String {
679    let home_candidates = home_candidates();
680    sanitize_error_message_with_homes(msg, &home_candidates)
681}
682
683/// Replaces the user's home directory with `~` and keeps everything else, including newlines.
684fn mask_home_paths(text: &str) -> String {
685    mask_home_paths_with(text, &home_candidates())
686}
687
688fn home_candidates() -> Vec<String> {
689    let mut candidates = Vec::new();
690    for var in ["HOME", "USERPROFILE"] {
691        if let Ok(home) = std::env::var(var)
692            && !home.trim().is_empty()
693            && home != "/"
694        {
695            let simplified = dunce::simplified(Path::new(&home))
696                .to_string_lossy()
697                .to_string();
698            if simplified != home {
699                candidates.push(simplified);
700            }
701            candidates.push(home);
702        }
703    }
704    candidates
705}
706
707fn mask_home_paths_with(text: &str, home_candidates: &[String]) -> String {
708    let mut masked = text.to_string();
709    for home in home_candidates {
710        let norm_home = to_forward_slash(&normalize_path(Path::new(home)));
711        masked = masked.replace(home.as_str(), "~");
712        if norm_home != *home {
713            masked = masked.replace(&norm_home, "~");
714        }
715        let backslash_home = home.replace('/', "\\");
716        if backslash_home != *home {
717            masked = masked.replace(&backslash_home, "~");
718        }
719    }
720    masked
721}
722
723fn sanitize_error_message_with_homes(msg: &str, home_candidates: &[String]) -> String {
724    let mut sanitized = mask_home_paths_with(msg, home_candidates);
725
726    // Replace newlines with spaces to avoid breaking markdown tables
727    sanitized = sanitized.replace("\r\n", " ").replace(['\n', '\r'], " ");
728
729    // Escape markdown table pipe characters
730    sanitized = sanitized.replace('|', "\\|");
731
732    // Truncate message to 500 characters
733    if sanitized.chars().count() > 500 {
734        let mut truncated: String = sanitized.chars().take(500).collect();
735        if truncated.ends_with('\\') && !truncated.ends_with("\\\\") {
736            truncated.pop();
737        }
738        truncated
739    } else {
740        sanitized
741    }
742}
743
744fn index_facts(workspace_root: &Path) -> Option<IndexFacts> {
745    let db_path = workspace_root.join(".code-kb").join("artifact.db");
746    let conn = crate::db::open_read_only(&db_path).ok()?;
747    let metadata = |key: &str| {
748        conn.query_row(
749            "SELECT value FROM artifact_metadata WHERE key = ?1",
750            [key],
751            |row| row.get::<_, String>(0),
752        )
753        .ok()
754    };
755    let count = |table: &str| {
756        conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
757            row.get::<_, i64>(0)
758        })
759        .unwrap_or(0)
760    };
761    Some(IndexFacts {
762        extractor_version: metadata("binary_version"),
763        schema_version: metadata("schema_version"),
764        index_level: metadata("index_level"),
765        updated_at: metadata("updated_at"),
766        file_count: count("files"),
767        symbol_count: count("symbols"),
768    })
769}
770
771fn log_tail(workspace_root: &Path, lines: usize) -> Vec<String> {
772    let mut tail: Vec<String> = Vec::new();
773    for path in crate::workspace::log_files_newest_first(&crate::workspace::log_dir(workspace_root))
774    {
775        if tail.len() >= lines {
776            break;
777        }
778        let Ok(content) = std::fs::read_to_string(path) else {
779            continue;
780        };
781        let wanted = lines - tail.len();
782        let file_lines: Vec<&str> = content.lines().collect();
783        let mut older: Vec<String> = file_lines[file_lines.len().saturating_sub(wanted)..]
784            .iter()
785            .map(|line| mask_home_paths(line))
786            .collect();
787        older.append(&mut tail);
788        tail = older;
789    }
790    tail
791}
792
793/// Browsers and GitHub truncate query strings past a few kilobytes.
794const MAX_ISSUE_URL_LEN: usize = 8000;
795
796fn build_issue_url(title: &str, body: &str) -> Result<url::Url, QueryError> {
797    let mut issue_url = url::Url::parse("https://github.com/anortham/code-kb/issues/new")
798        .map_err(|e| QueryError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(e))))?;
799    issue_url
800        .query_pairs_mut()
801        .append_pair("title", title)
802        .append_pair("body", body);
803    Ok(issue_url)
804}
805
806/// Builds the diagnostic bundle for a GitHub issue. `markdown_body` is complete. The
807/// pre-filled issue URL omits the log tail, and falls back to the environment and index
808/// sections alone when the rest would push it past `MAX_ISSUE_URL_LEN`.
809pub fn generate_bug_report(
810    conn: &Connection,
811    workspace_root: Option<&Path>,
812    issue_title: Option<&str>,
813    description: Option<&str>,
814    log_lines: usize,
815) -> Result<BugReportBundle, QueryError> {
816    let os_info = std::env::consts::OS.to_string();
817    let arch_info = std::env::consts::ARCH.to_string();
818    let code_kb_version = env!("CARGO_PKG_VERSION").to_string();
819
820    let exe_name = if cfg!(windows) {
821        "julie-extract.exe"
822    } else {
823        "julie-extract"
824    };
825
826    let sibling_binary = std::env::current_exe()
827        .ok()
828        .and_then(|p| p.parent().map(|d| d.join(exe_name)))
829        .filter(|p| p.is_file());
830
831    let julie_extract_version = if let Some(bin) = sibling_binary {
832        if let Ok(output) = std::process::Command::new(&bin).arg("--version").output() {
833            let ver = String::from_utf8_lossy(&output.stdout).trim().to_string();
834            if !ver.is_empty() {
835                ver
836            } else {
837                crate::sync::PINNED_JULIE_VERSION.to_string()
838            }
839        } else {
840            crate::sync::PINNED_JULIE_VERSION.to_string()
841        }
842    } else {
843        crate::sync::PINNED_JULIE_VERSION.to_string()
844    };
845
846    let active_workspace_name = workspace_root.map(|ws| {
847        let norm = to_forward_slash(&normalize_path(ws));
848        Path::new(&norm)
849            .file_name()
850            .map(|n| n.to_string_lossy().to_string())
851            .unwrap_or_else(|| "repo".to_string())
852    });
853
854    // Query recent errors
855    let mut recent_errors = Vec::new();
856    let (error_sql, ws_param, alt_ws_param) = if let Some(ws) = workspace_root {
857        let (canon, alt) = workspace_root_match_candidates(ws);
858        if alt.is_some() {
859            (
860                "SELECT timestamp, tool, error_message
861                 FROM tool_telemetry
862                 WHERE outcome = 'error' AND error_message IS NOT NULL AND (workspace_root = ?1 OR workspace_root = ?2)
863                 ORDER BY timestamp DESC
864                 LIMIT 10",
865                Some(canon),
866                alt,
867            )
868        } else {
869            (
870                "SELECT timestamp, tool, error_message
871                 FROM tool_telemetry
872                 WHERE outcome = 'error' AND error_message IS NOT NULL AND workspace_root = ?1
873                 ORDER BY timestamp DESC
874                 LIMIT 10",
875                Some(canon),
876                None,
877            )
878        }
879    } else {
880        (
881            "SELECT timestamp, tool, error_message
882             FROM tool_telemetry
883             WHERE outcome = 'error' AND error_message IS NOT NULL
884             ORDER BY timestamp DESC
885             LIMIT 10",
886            None,
887            None,
888        )
889    };
890
891    {
892        let mut stmt = conn.prepare(error_sql)?;
893        let row_mapper = |row: &rusqlite::Row| {
894            let raw_msg: String = row.get(2)?;
895            Ok(TelemetryErrorRecord {
896                timestamp: row.get(0)?,
897                tool: row.get(1)?,
898                error_message: sanitize_error_message(&raw_msg),
899            })
900        };
901        match (&ws_param, &alt_ws_param) {
902            (Some(ws), Some(alt)) => {
903                let rows = stmt.query_map(params![ws, alt], row_mapper)?;
904                for err in rows.flatten() {
905                    recent_errors.push(err);
906                }
907            }
908            (Some(ws), None) => {
909                let rows = stmt.query_map(params![ws], row_mapper)?;
910                for err in rows.flatten() {
911                    recent_errors.push(err);
912                }
913            }
914            _ => {
915                let rows = stmt.query_map([], row_mapper)?;
916                for err in rows.flatten() {
917                    recent_errors.push(err);
918                }
919            }
920        }
921    }
922
923    // Build markdown body
924    let mut markdown = String::new();
925    markdown.push_str("### Environment\n");
926    markdown.push_str(&format!("- **OS:** {}\n", os_info));
927    markdown.push_str(&format!("- **Architecture:** {}\n", arch_info));
928    markdown.push_str(&format!("- **code-kb Version:** {}\n", code_kb_version));
929    markdown.push_str(&format!(
930        "- **julie-extract Version:** {}\n",
931        julie_extract_version
932    ));
933    if let Some(ref ws) = active_workspace_name {
934        markdown.push_str(&format!("- **Active Workspace:** {}\n", ws));
935    }
936
937    let index = workspace_root.and_then(index_facts);
938    markdown.push_str("\n### Index\n");
939    match &index {
940        Some(facts) => {
941            let field = |value: &Option<String>| value.clone().unwrap_or_else(|| "unknown".into());
942            markdown.push_str(&format!(
943                "- **Extractor:** {} (schema {}, level {})\n- **Updated:** {}\n- **Files / Symbols:** {} / {}\n",
944                field(&facts.extractor_version),
945                field(&facts.schema_version),
946                field(&facts.index_level),
947                field(&facts.updated_at),
948                facts.file_count,
949                facts.symbol_count
950            ));
951        }
952        None => markdown.push_str("- No `.code-kb/artifact.db` in the active workspace.\n"),
953    }
954
955    let short_body = markdown.clone();
956    markdown.push_str("\n### Description\n");
957    match description.map(str::trim).filter(|text| !text.is_empty()) {
958        Some(text) => markdown.push_str(&format!("{}\n\n", mask_home_paths(text))),
959        None => markdown.push_str("<!-- Please describe the bug or unexpected behavior -->\n\n"),
960    }
961
962    if !recent_errors.is_empty() {
963        markdown.push_str("### Recent Telemetry Errors\n");
964        markdown.push_str("| Timestamp | Tool | Error Message |\n");
965        markdown.push_str("|---|---|---|\n");
966        for err in &recent_errors {
967            markdown.push_str(&format!(
968                "| {} | `{}` | {} |\n",
969                err.timestamp, err.tool, err.error_message
970            ));
971        }
972    }
973
974    let title_str = issue_title
975        .map(sanitize_error_message)
976        .unwrap_or_else(|| "Bug report".to_string());
977    let mut issue_url = build_issue_url(&title_str, &markdown)?;
978    if issue_url.as_str().len() > MAX_ISSUE_URL_LEN {
979        let short_body = format!(
980            "{short_body}\n<!-- The full report was too long for a URL. Paste the output of `code-kb bug-report` here. -->\n"
981        );
982        issue_url = build_issue_url(&title_str, &short_body)?;
983    }
984
985    let log_tail = workspace_root
986        .map(|root| log_tail(root, log_lines))
987        .unwrap_or_default();
988    if !log_tail.is_empty() {
989        markdown.push_str(&format!(
990            "\n### Recent Log Lines\n```text\n{}\n```\n",
991            log_tail.join("\n")
992        ));
993    }
994
995    Ok(BugReportBundle {
996        os_info,
997        arch_info,
998        code_kb_version,
999        julie_extract_version,
1000        active_workspace_name,
1001        index,
1002        recent_errors,
1003        log_tail,
1004        markdown_body: markdown,
1005        github_issue_url: issue_url.to_string(),
1006    })
1007}
1008
1009pub fn format_telemetry_summary(summary: &TelemetrySummary) -> String {
1010    let mut out = String::new();
1011    out.push_str("=================================================================\n");
1012    out.push_str("                    code-kb Telemetry Summary                    \n");
1013    out.push_str("=================================================================\n");
1014
1015    if summary.total_calls == 0 {
1016        out.push_str(&format!(
1017            "Scope: {} | Window: {}\nNo tool calls recorded for this scope yet.\n",
1018            summary.scope_description, summary.time_window
1019        ));
1020        return out;
1021    }
1022
1023    let success_rate = if summary.total_calls > 0 {
1024        ((summary.ok_calls + summary.empty_calls) as f64 / summary.total_calls as f64) * 100.0
1025    } else {
1026        0.0
1027    };
1028
1029    out.push_str(&format!(
1030        "Scope: {} | Window: {} | Total Tool Calls: {} | Success Rate: {:.1}% | Empty Results: {} | Tokens Served: ~{} | Est. Tokens Saved: ~{} (baseline known for {} of {} calls)\n\n",
1031        summary.scope_description,
1032        summary.time_window,
1033        summary.total_calls,
1034        success_rate,
1035        summary.empty_calls,
1036        summary.total_tokens_returned,
1037        summary.est_tokens_saved,
1038        summary.saved_known_calls,
1039        summary.total_calls
1040    ));
1041
1042    out.push_str("### Tool Invocations & Performance\n");
1043    out.push_str(
1044        "| Tool | Calls | Empty | Latency (p50 / p95 / avg) | Query / Reconcile | Tokens Served | Est. Tokens Saved | Success Rate |\n",
1045    );
1046    out.push_str("|---|---:|---:|---:|---:|---:|---:|---:|\n");
1047
1048    for stat in &summary.tool_stats {
1049        let rate = if stat.count > 0 {
1050            ((stat.ok_count + stat.empty_count) as f64 / stat.count as f64) * 100.0
1051        } else {
1052            0.0
1053        };
1054        let p50_str = stat
1055            .p50_ms
1056            .map(|v| format!("{}ms", v))
1057            .unwrap_or_else(|| "-".to_string());
1058        let p95_str = stat
1059            .p95_ms
1060            .map(|v| format!("{}ms", v))
1061            .unwrap_or_else(|| "-".to_string());
1062        let latency_str = format!("{} / {} / {}ms", p50_str, p95_str, stat.avg_duration_ms);
1063
1064        let query_str = stat
1065            .avg_query_ms
1066            .map(|v| format!("{}ms", v))
1067            .unwrap_or_else(|| "-".to_string());
1068        let rec_str = stat
1069            .avg_reconcile_ms
1070            .map(|v| format!("{}ms", v))
1071            .unwrap_or_else(|| "-".to_string());
1072        let phase_str = format!("{} / {}", query_str, rec_str);
1073
1074        out.push_str(&format!(
1075            "| `{}` | {} | {} | {} | {} | ~{} | ~{} ({}/{}) | {:.1}% |\n",
1076            stat.tool,
1077            stat.count,
1078            stat.empty_count,
1079            latency_str,
1080            phase_str,
1081            stat.tokens_returned,
1082            stat.tokens_saved,
1083            stat.saved_known_count,
1084            stat.count,
1085            rate
1086        ));
1087    }
1088
1089    if !summary.recent_errors.is_empty() {
1090        out.push_str("\n### Recent Errors\n");
1091        for err in &summary.recent_errors {
1092            out.push_str(&format!(
1093                "- {} [`{}`]: {}\n",
1094                err.timestamp, err.tool, err.error_message
1095            ));
1096        }
1097    }
1098
1099    out
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104    use super::*;
1105
1106    #[test]
1107    fn test_global_telemetry_dir_isolation() {
1108        let custom_dir = PathBuf::from("/custom/telemetry/path");
1109        let target_tmp = PathBuf::from("/workspace/target/tmp");
1110        let home_dir = PathBuf::from("/home/user");
1111        let profile_dir = PathBuf::from("C:\\Users\\user");
1112
1113        // 1. Explicit CODE_KB_TELEMETRY_DIR takes highest precedence
1114        assert_eq!(
1115            resolve_telemetry_dir(
1116                Some(custom_dir.to_string_lossy().to_string()),
1117                Some(target_tmp.to_string_lossy().to_string()),
1118                Some(home_dir.to_string_lossy().to_string()),
1119                Some(profile_dir.to_string_lossy().to_string()),
1120            ),
1121            custom_dir
1122        );
1123
1124        // 2. CARGO_TARGET_TMPDIR isolates tests when explicit dir is absent
1125        assert_eq!(
1126            resolve_telemetry_dir(
1127                None,
1128                Some(target_tmp.to_string_lossy().to_string()),
1129                Some(home_dir.to_string_lossy().to_string()),
1130                Some(profile_dir.to_string_lossy().to_string()),
1131            ),
1132            target_tmp.join("test-telemetry")
1133        );
1134
1135        // 3. HOME directory fallback
1136        assert_eq!(
1137            resolve_telemetry_dir(
1138                None,
1139                None,
1140                Some(home_dir.to_string_lossy().to_string()),
1141                Some(profile_dir.to_string_lossy().to_string()),
1142            ),
1143            home_dir.join(".code-kb")
1144        );
1145
1146        // 4. USERPROFILE directory fallback
1147        assert_eq!(
1148            resolve_telemetry_dir(
1149                None,
1150                None,
1151                None,
1152                Some(profile_dir.to_string_lossy().to_string()),
1153            ),
1154            profile_dir.join(".code-kb")
1155        );
1156
1157        // 5. Default current working dir
1158        assert_eq!(
1159            resolve_telemetry_dir(None, None, None, None),
1160            PathBuf::from(".code-kb")
1161        );
1162
1163        let temp = crate::safe_tempdir();
1164        set_telemetry_dir_override(Some(temp.path().to_path_buf()));
1165        let dir = get_global_telemetry_dir();
1166        assert_eq!(dir, temp.path());
1167
1168        let conn = open_global_telemetry_db().expect("open_global_telemetry_db should succeed");
1169        assert!(temp.path().join("telemetry.db").exists());
1170        drop(conn);
1171        set_telemetry_dir_override(None);
1172    }
1173
1174    #[test]
1175    fn test_telemetry_disabled_flags() {
1176        assert!(is_telemetry_disabled_with(Some("1"), None));
1177        assert!(is_telemetry_disabled_with(Some("true"), None));
1178        assert!(is_telemetry_disabled_with(Some("TRUE"), None));
1179        assert!(is_telemetry_disabled_with(None, Some("1")));
1180        assert!(is_telemetry_disabled_with(None, Some("true")));
1181        assert!(is_telemetry_disabled_with(None, Some("TRUE")));
1182
1183        assert!(!is_telemetry_disabled_with(Some("0"), None));
1184        assert!(!is_telemetry_disabled_with(Some("false"), None));
1185        assert!(!is_telemetry_disabled_with(None, Some("0")));
1186        assert!(!is_telemetry_disabled_with(None, None));
1187    }
1188
1189    #[test]
1190    fn test_telemetry_filter_time_windows() {
1191        assert_eq!(TimeWindow::parse("today"), Some(TimeWindow::Today));
1192        assert_eq!(TimeWindow::parse("7d"), Some(TimeWindow::Last7Days));
1193        assert_eq!(TimeWindow::parse("week"), Some(TimeWindow::Last7Days));
1194        assert_eq!(TimeWindow::parse("30d"), Some(TimeWindow::Last30Days));
1195        assert_eq!(TimeWindow::parse("month"), Some(TimeWindow::ThisMonth));
1196        assert_eq!(TimeWindow::parse("this-month"), Some(TimeWindow::ThisMonth));
1197        assert_eq!(TimeWindow::parse("year"), Some(TimeWindow::LastYear));
1198        assert_eq!(TimeWindow::parse("last-year"), Some(TimeWindow::LastYear));
1199        assert_eq!(TimeWindow::parse("all"), Some(TimeWindow::AllTime));
1200        assert_eq!(TimeWindow::parse("all-time"), Some(TimeWindow::AllTime));
1201        assert_eq!(TimeWindow::parse("invalid"), None);
1202
1203        let temp = crate::safe_tempdir();
1204        let conn = open_telemetry_db_at(temp.path()).expect("open db");
1205
1206        let ws_root = Path::new("/workspace/test");
1207        let norm_ws =
1208            crate::workspace::to_forward_slash(&crate::workspace::normalize_path(ws_root));
1209
1210        conn.execute(
1211            "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
1212             VALUES ('id1', datetime('now'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
1213            params![norm_ws],
1214        ).unwrap();
1215
1216        conn.execute(
1217            "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
1218             VALUES ('id2', datetime('now', '-2 days'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
1219            params![norm_ws],
1220        ).unwrap();
1221
1222        conn.execute(
1223            "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
1224             VALUES ('id3', datetime('now', '-15 days'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
1225            params![norm_ws],
1226        ).unwrap();
1227
1228        conn.execute(
1229            "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
1230             VALUES ('id4', datetime('now', '-60 days'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
1231            params![norm_ws],
1232        ).unwrap();
1233
1234        let s_today = get_telemetry_summary(
1235            &conn,
1236            &TelemetryFilter {
1237                time_window: TimeWindow::Today,
1238                workspace_root: None,
1239                version: None,
1240            },
1241        )
1242        .unwrap();
1243        assert_eq!(s_today.total_calls, 1);
1244
1245        let s_7d = get_telemetry_summary(
1246            &conn,
1247            &TelemetryFilter {
1248                time_window: TimeWindow::Last7Days,
1249                workspace_root: None,
1250                version: None,
1251            },
1252        )
1253        .unwrap();
1254        assert_eq!(s_7d.total_calls, 2);
1255
1256        let s_30d = get_telemetry_summary(
1257            &conn,
1258            &TelemetryFilter {
1259                time_window: TimeWindow::Last30Days,
1260                workspace_root: None,
1261                version: None,
1262            },
1263        )
1264        .unwrap();
1265        assert_eq!(s_30d.total_calls, 3);
1266
1267        let s_year = get_telemetry_summary(
1268            &conn,
1269            &TelemetryFilter {
1270                time_window: TimeWindow::LastYear,
1271                workspace_root: None,
1272                version: None,
1273            },
1274        )
1275        .unwrap();
1276        assert_eq!(s_year.total_calls, 4);
1277
1278        let s_all = get_telemetry_summary(
1279            &conn,
1280            &TelemetryFilter {
1281                time_window: TimeWindow::AllTime,
1282                workspace_root: None,
1283                version: None,
1284            },
1285        )
1286        .unwrap();
1287        assert_eq!(s_all.total_calls, 4);
1288    }
1289
1290    #[test]
1291    fn test_telemetry_workspace_scoping() {
1292        let temp = crate::safe_tempdir();
1293        let conn = open_telemetry_db_at(temp.path()).expect("open db");
1294
1295        let ws_a = Path::new("/projects/alpha");
1296        let ws_b = Path::new("/projects/beta");
1297
1298        let inv_a = ToolInvocation {
1299            tool: "find_symbol",
1300            duration_ms: 15,
1301            outcome: "ok",
1302            error_message: None,
1303            logical_result_count: Some(1),
1304            bytes_returned: 100,
1305            est_tokens: 25,
1306            est_tokens_saved: 100,
1307            est_tokens_saved_known: true,
1308            reconcile_ms: None,
1309            query_ms: None,
1310        };
1311        record_tool_call_conn(&conn, ws_a, &inv_a);
1312        record_tool_call_conn(&conn, ws_a, &inv_a);
1313
1314        let inv_b = ToolInvocation {
1315            tool: "file_skeleton",
1316            duration_ms: 8,
1317            outcome: "ok",
1318            error_message: None,
1319            logical_result_count: Some(1),
1320            bytes_returned: 200,
1321            est_tokens: 50,
1322            est_tokens_saved: 200,
1323            est_tokens_saved_known: true,
1324            reconcile_ms: None,
1325            query_ms: None,
1326        };
1327        record_tool_call_conn(&conn, ws_b, &inv_b);
1328
1329        let filter_a = TelemetryFilter {
1330            time_window: TimeWindow::AllTime,
1331            workspace_root: Some(ws_a.to_path_buf()),
1332            version: None,
1333        };
1334        let sum_a = get_telemetry_summary(&conn, &filter_a).unwrap();
1335        assert_eq!(sum_a.total_calls, 2);
1336        assert_eq!(sum_a.tool_stats.len(), 1);
1337        assert_eq!(sum_a.tool_stats[0].tool, "find_symbol");
1338        assert!(sum_a.scope_description.contains("alpha"));
1339
1340        let filter_b = TelemetryFilter {
1341            time_window: TimeWindow::AllTime,
1342            workspace_root: Some(ws_b.to_path_buf()),
1343            version: None,
1344        };
1345        let sum_b = get_telemetry_summary(&conn, &filter_b).unwrap();
1346        assert_eq!(sum_b.total_calls, 1);
1347        assert_eq!(sum_b.tool_stats.len(), 1);
1348        assert_eq!(sum_b.tool_stats[0].tool, "file_skeleton");
1349        assert!(sum_b.scope_description.contains("beta"));
1350
1351        let filter_global = TelemetryFilter {
1352            time_window: TimeWindow::AllTime,
1353            workspace_root: None,
1354            version: None,
1355        };
1356        let sum_global = get_telemetry_summary(&conn, &filter_global).unwrap();
1357        assert_eq!(sum_global.total_calls, 3);
1358        assert!(
1359            sum_global
1360                .scope_description
1361                .contains("Global (all workspaces)")
1362        );
1363    }
1364
1365    #[test]
1366    fn test_est_tokens_saved_aggregation() {
1367        let temp = crate::safe_tempdir();
1368        let conn = open_telemetry_db_at(temp.path()).expect("open db");
1369        let ws = Path::new("/projects/token_test");
1370
1371        let inv1 = ToolInvocation {
1372            tool: "file_skeleton",
1373            duration_ms: 10,
1374            outcome: "ok",
1375            error_message: None,
1376            logical_result_count: Some(5),
1377            bytes_returned: 1000,
1378            est_tokens: 250,
1379            est_tokens_saved: 750,
1380            est_tokens_saved_known: true,
1381            reconcile_ms: None,
1382            query_ms: None,
1383        };
1384        let inv2 = ToolInvocation {
1385            tool: "file_skeleton",
1386            duration_ms: 20,
1387            outcome: "ok",
1388            error_message: None,
1389            logical_result_count: Some(3),
1390            bytes_returned: 600,
1391            est_tokens: 150,
1392            est_tokens_saved: 450,
1393            est_tokens_saved_known: true,
1394            reconcile_ms: None,
1395            query_ms: None,
1396        };
1397        let inv3 = ToolInvocation {
1398            tool: "get_symbol_body",
1399            duration_ms: 30,
1400            outcome: "ok",
1401            error_message: None,
1402            logical_result_count: Some(1),
1403            bytes_returned: 200,
1404            est_tokens: 50,
1405            est_tokens_saved: 500,
1406            est_tokens_saved_known: true,
1407            reconcile_ms: None,
1408            query_ms: None,
1409        };
1410
1411        record_tool_call_conn(&conn, ws, &inv1);
1412        record_tool_call_conn(&conn, ws, &inv2);
1413        record_tool_call_conn(&conn, ws, &inv3);
1414
1415        let filter = TelemetryFilter::default();
1416        let summary = get_telemetry_summary(&conn, &filter).unwrap();
1417
1418        assert_eq!(summary.total_calls, 3);
1419        assert_eq!(summary.total_tokens_returned, 450);
1420        assert_eq!(summary.est_tokens_saved, 1700);
1421
1422        let skel_stat = summary
1423            .tool_stats
1424            .iter()
1425            .find(|s| s.tool == "file_skeleton")
1426            .unwrap();
1427        assert_eq!(skel_stat.count, 2);
1428        assert_eq!(skel_stat.tokens_returned, 400);
1429        assert_eq!(skel_stat.tokens_saved, 1200);
1430        assert_eq!(skel_stat.avg_duration_ms, 15);
1431
1432        let sym_stat = summary
1433            .tool_stats
1434            .iter()
1435            .find(|s| s.tool == "get_symbol_body")
1436            .unwrap();
1437        assert_eq!(sym_stat.count, 1);
1438        assert_eq!(sym_stat.tokens_returned, 50);
1439        assert_eq!(sym_stat.tokens_saved, 500);
1440        assert_eq!(sym_stat.avg_duration_ms, 30);
1441    }
1442
1443    #[test]
1444    fn test_saved_baseline_coverage_counts_known_and_unknown_apart() {
1445        let temp = crate::safe_tempdir();
1446        let conn = open_telemetry_db_at(temp.path()).expect("open db");
1447        let ws = Path::new("/projects/coverage_test");
1448
1449        let known = ToolInvocation {
1450            tool: "find_references",
1451            duration_ms: 10,
1452            outcome: "ok",
1453            error_message: None,
1454            logical_result_count: Some(4),
1455            bytes_returned: 400,
1456            est_tokens: 100,
1457            est_tokens_saved: 900,
1458            est_tokens_saved_known: true,
1459            reconcile_ms: None,
1460            query_ms: None,
1461        };
1462        let unknown = ToolInvocation {
1463            tool: "find_references",
1464            duration_ms: 12,
1465            outcome: "ok",
1466            error_message: None,
1467            logical_result_count: Some(0),
1468            bytes_returned: 40,
1469            est_tokens: 10,
1470            est_tokens_saved: 0,
1471            est_tokens_saved_known: false,
1472            reconcile_ms: None,
1473            query_ms: None,
1474        };
1475        let outline = ToolInvocation {
1476            tool: "codebase_outline",
1477            duration_ms: 8,
1478            outcome: "ok",
1479            error_message: None,
1480            logical_result_count: Some(7),
1481            bytes_returned: 200,
1482            est_tokens: 50,
1483            est_tokens_saved: 0,
1484            est_tokens_saved_known: false,
1485            reconcile_ms: None,
1486            query_ms: None,
1487        };
1488
1489        record_tool_call_conn(&conn, ws, &known);
1490        record_tool_call_conn(&conn, ws, &unknown);
1491        record_tool_call_conn(&conn, ws, &outline);
1492
1493        let summary = get_telemetry_summary(&conn, &TelemetryFilter::default()).unwrap();
1494        assert_eq!(summary.total_calls, 3);
1495        assert_eq!(summary.est_tokens_saved, 900);
1496        assert_eq!(summary.saved_known_calls, 1);
1497
1498        let refs_stat = summary
1499            .tool_stats
1500            .iter()
1501            .find(|s| s.tool == "find_references")
1502            .unwrap();
1503        assert_eq!(refs_stat.count, 2);
1504        assert_eq!(refs_stat.saved_known_count, 1);
1505
1506        let outline_stat = summary
1507            .tool_stats
1508            .iter()
1509            .find(|s| s.tool == "codebase_outline")
1510            .unwrap();
1511        assert_eq!(outline_stat.saved_known_count, 0);
1512
1513        let formatted = format_telemetry_summary(&summary);
1514        assert!(
1515            formatted.contains("Est. Tokens Saved: ~900 (baseline known for 1 of 3 calls)"),
1516            "{formatted}"
1517        );
1518        assert!(formatted.contains("~900 (1/2)"), "{formatted}");
1519        assert!(formatted.contains("~0 (0/1)"), "{formatted}");
1520    }
1521
1522    #[test]
1523    fn test_bug_report_bundle_generation() {
1524        let temp = crate::safe_tempdir();
1525        let conn = open_telemetry_db_at(temp.path()).unwrap();
1526        let ws = Path::new("/home/user/src/code-kb");
1527
1528        let inv_err = ToolInvocation {
1529            tool: "replace_symbol_body",
1530            duration_ms: 50,
1531            outcome: "error",
1532            error_message: Some("Tree-sitter parse failure on invalid syntax"),
1533            logical_result_count: None,
1534            bytes_returned: 0,
1535            est_tokens: 0,
1536            est_tokens_saved: 0,
1537            est_tokens_saved_known: true,
1538            reconcile_ms: None,
1539            query_ms: None,
1540        };
1541        record_tool_call_conn(&conn, ws, &inv_err);
1542
1543        let bundle = generate_bug_report(&conn, Some(ws), Some("Parser failure"), None, 0).unwrap();
1544        assert_eq!(bundle.code_kb_version, env!("CARGO_PKG_VERSION"));
1545        assert!(!bundle.os_info.is_empty());
1546        assert!(!bundle.arch_info.is_empty());
1547        assert!(
1548            bundle
1549                .julie_extract_version
1550                .contains(crate::sync::PINNED_JULIE_VERSION)
1551        );
1552        assert_eq!(bundle.active_workspace_name, Some("code-kb".to_string()));
1553        assert_eq!(bundle.recent_errors.len(), 1);
1554        assert!(
1555            bundle.recent_errors[0]
1556                .error_message
1557                .contains("Tree-sitter parse failure")
1558        );
1559
1560        assert!(bundle.markdown_body.contains("code-kb"));
1561        assert!(bundle.markdown_body.contains(&bundle.os_info));
1562        assert!(bundle.markdown_body.contains("Tree-sitter parse failure"));
1563
1564        assert!(
1565            bundle
1566                .github_issue_url
1567                .starts_with("https://github.com/anortham/code-kb/issues/new?")
1568        );
1569        assert!(bundle.github_issue_url.contains("title=Parser"));
1570
1571        let parsed_url = url::Url::parse(&bundle.github_issue_url).unwrap();
1572        assert_eq!(parsed_url.host_str(), Some("github.com"));
1573    }
1574
1575    #[test]
1576    fn test_logical_result_count_persists_known_empty_and_nonempty_results() {
1577        let temp = crate::safe_tempdir();
1578        let conn = open_telemetry_db_at(temp.path()).expect("open db");
1579        let root = temp.path();
1580
1581        for (tool, outcome, logical_result_count) in [
1582            ("search_symbols", "empty", Some(0)),
1583            ("search_symbols", "ok", Some(3)),
1584        ] {
1585            record_tool_call_conn(
1586                &conn,
1587                root,
1588                &ToolInvocation {
1589                    tool,
1590                    duration_ms: 1,
1591                    outcome,
1592                    error_message: None,
1593                    logical_result_count,
1594                    bytes_returned: 10,
1595                    est_tokens: 2,
1596                    est_tokens_saved: 0,
1597                    est_tokens_saved_known: true,
1598                    reconcile_ms: None,
1599                    query_ms: None,
1600                },
1601            );
1602        }
1603
1604        let counts = conn
1605            .prepare("SELECT result_count, result_count_known FROM tool_telemetry ORDER BY rowid")
1606            .unwrap()
1607            .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))
1608            .unwrap()
1609            .collect::<Result<Vec<_>, _>>()
1610            .unwrap();
1611        assert_eq!(counts, vec![(0, 1), (3, 1)]);
1612
1613        let summary = get_telemetry_summary(&conn, &TelemetryFilter::default()).unwrap();
1614        assert_eq!(summary.empty_calls, 1);
1615        assert_eq!(summary.ok_calls, 1);
1616        assert_eq!(summary.tool_stats[0].empty_count, 1);
1617        let formatted = format_telemetry_summary(&summary);
1618        assert!(formatted.contains("Success Rate: 100.0%"));
1619        assert!(formatted.contains("Empty Results: 1"));
1620        assert!(formatted.contains("| `search_symbols` | 2 | 1 |"));
1621    }
1622
1623    #[test]
1624    fn test_telemetry_recording_and_summary() {
1625        let temp = crate::safe_tempdir();
1626        let root = temp.path();
1627        let conn = open_telemetry_db_at(temp.path()).expect("open db");
1628
1629        let inv1 = ToolInvocation {
1630            tool: "file_skeleton",
1631            duration_ms: 6,
1632            outcome: "ok",
1633            error_message: None,
1634            logical_result_count: Some(5),
1635            bytes_returned: 1200,
1636            est_tokens: 300,
1637            est_tokens_saved: 900,
1638            est_tokens_saved_known: true,
1639            reconcile_ms: None,
1640            query_ms: None,
1641        };
1642        record_tool_call_conn(&conn, root, &inv1);
1643
1644        let inv2 = ToolInvocation {
1645            tool: "file_skeleton",
1646            duration_ms: 4,
1647            outcome: "ok",
1648            error_message: None,
1649            logical_result_count: Some(3),
1650            bytes_returned: 800,
1651            est_tokens: 200,
1652            est_tokens_saved: 600,
1653            est_tokens_saved_known: true,
1654            reconcile_ms: None,
1655            query_ms: None,
1656        };
1657        record_tool_call_conn(&conn, root, &inv2);
1658
1659        let inv3 = ToolInvocation {
1660            tool: "replace_symbol_body",
1661            duration_ms: 12,
1662            outcome: "error",
1663            error_message: Some("Syntax error in Rust function"),
1664            logical_result_count: None,
1665            bytes_returned: 50,
1666            est_tokens: 12,
1667            est_tokens_saved: 0,
1668            est_tokens_saved_known: true,
1669            reconcile_ms: None,
1670            query_ms: None,
1671        };
1672        record_tool_call_conn(&conn, root, &inv3);
1673
1674        let summary = get_telemetry_summary(&conn, &TelemetryFilter::default()).unwrap();
1675        assert_eq!(summary.total_calls, 3);
1676        assert_eq!(summary.ok_calls, 2);
1677        assert_eq!(summary.error_calls, 1);
1678        assert_eq!(summary.total_tokens_returned, 512);
1679        assert_eq!(summary.est_tokens_saved, 1500);
1680
1681        assert_eq!(summary.tool_stats.len(), 2);
1682        let skel_stat = summary
1683            .tool_stats
1684            .iter()
1685            .find(|s| s.tool == "file_skeleton")
1686            .unwrap();
1687        assert_eq!(skel_stat.count, 2);
1688        assert_eq!(skel_stat.ok_count, 2);
1689        assert_eq!(skel_stat.avg_duration_ms, 5);
1690
1691        assert_eq!(summary.recent_errors.len(), 1);
1692        assert_eq!(summary.recent_errors[0].tool, "replace_symbol_body");
1693        assert!(
1694            summary.recent_errors[0]
1695                .error_message
1696                .contains("Syntax error")
1697        );
1698
1699        let formatted = format_telemetry_summary(&summary);
1700        assert!(formatted.contains("Total Tool Calls: 3"));
1701        assert!(formatted.contains("| `file_skeleton` | 2 |"));
1702        assert!(formatted.contains("Syntax error in Rust function"));
1703    }
1704
1705    #[test]
1706    fn test_old_schema_upgrade() {
1707        let temp = crate::safe_tempdir();
1708        let db_path = temp.path().join("telemetry.db");
1709        let conn = Connection::open(&db_path).unwrap();
1710
1711        // Create old schema v1 without workspace_root, workspace_name, or est_tokens_saved
1712        conn.execute_batch(
1713            "CREATE TABLE tool_telemetry (
1714                id TEXT PRIMARY KEY,
1715                timestamp TEXT NOT NULL,
1716                tool TEXT NOT NULL,
1717                duration_ms INTEGER NOT NULL,
1718                outcome TEXT NOT NULL,
1719                error_message TEXT,
1720                result_count INTEGER NOT NULL DEFAULT 0,
1721                bytes_returned INTEGER NOT NULL DEFAULT 0,
1722                est_tokens INTEGER NOT NULL DEFAULT 0,
1723                code_kb_version TEXT NOT NULL
1724            );
1725            INSERT INTO tool_telemetry VALUES (
1726                'old1', '2026-09-01 12:00:00', 'find_symbol', 10, 'ok', NULL, 1, 50, 12, '0.6.0'
1727            );",
1728        )
1729        .unwrap();
1730
1731        // Running init_telemetry_db must migrate columns before creating index
1732        init_telemetry_db(&conn).expect("schema upgrade should succeed on legacy DB");
1733
1734        // Verify that workspace_root was added and idx_tool_telemetry_ws_ts was created
1735        let summary = get_telemetry_summary(&conn, &TelemetryFilter::default()).unwrap();
1736        assert_eq!(summary.total_calls, 1);
1737        assert_eq!(summary.ok_calls, 1);
1738        assert_eq!(
1739            conn.query_row(
1740                "SELECT result_count_known FROM tool_telemetry WHERE id = 'old1'",
1741                [],
1742                |row| row.get::<_, i64>(0),
1743            )
1744            .unwrap(),
1745            0
1746        );
1747        assert_eq!(
1748            conn.query_row(
1749                "SELECT est_tokens_saved_known FROM tool_telemetry WHERE id = 'old1'",
1750                [],
1751                |row| row.get::<_, i64>(0),
1752            )
1753            .unwrap(),
1754            0
1755        );
1756        assert_eq!(
1757            get_telemetry_summary(&conn, &TelemetryFilter::default())
1758                .unwrap()
1759                .saved_known_calls,
1760            0
1761        );
1762
1763        // Verify we can insert a new record with workspace_root and query via index
1764        let inv = ToolInvocation {
1765            tool: "file_skeleton",
1766            duration_ms: 5,
1767            outcome: "ok",
1768            error_message: None,
1769            logical_result_count: Some(1),
1770            bytes_returned: 100,
1771            est_tokens: 25,
1772            est_tokens_saved: 75,
1773            est_tokens_saved_known: true,
1774            reconcile_ms: None,
1775            query_ms: None,
1776        };
1777        record_tool_call_conn(&conn, Path::new("/workspace/project"), &inv);
1778
1779        let ws_filter = TelemetryFilter {
1780            time_window: TimeWindow::AllTime,
1781            workspace_root: Some(PathBuf::from("/workspace/project")),
1782            version: None,
1783        };
1784        let ws_summary = get_telemetry_summary(&conn, &ws_filter).unwrap();
1785        assert_eq!(ws_summary.total_calls, 1);
1786    }
1787
1788    #[test]
1789    fn test_bug_report_includes_index_facts_description_and_masked_log_tail() {
1790        let telemetry_dir = crate::safe_tempdir();
1791        let conn = open_telemetry_db_at(telemetry_dir.path()).unwrap();
1792        let workspace = crate::safe_tempdir();
1793        let root = workspace.path();
1794        let kb_dir = root.join(".code-kb");
1795        std::fs::create_dir_all(kb_dir.join("logs")).unwrap();
1796        let index = Connection::open(kb_dir.join("artifact.db")).unwrap();
1797        index
1798            .execute_batch(
1799                "CREATE TABLE artifact_metadata (key TEXT PRIMARY KEY, value TEXT);
1800                 INSERT INTO artifact_metadata VALUES ('binary_version', '3.1.1'), ('schema_version', '7'), ('index_level', 'facts');
1801                 CREATE TABLE files (file_id TEXT); INSERT INTO files VALUES ('a'), ('b');
1802                 CREATE TABLE symbols (symbol_id TEXT); INSERT INTO symbols VALUES ('s');",
1803            )
1804            .unwrap();
1805        drop(index);
1806        let home = std::env::var("HOME")
1807            .or_else(|_| std::env::var("USERPROFILE"))
1808            .unwrap_or_else(|_| "/default/home".to_string());
1809        let logs = kb_dir.join("logs");
1810        std::fs::write(
1811            logs.join("code-kb.log.2026-09-19"),
1812            "older one\nolder two\n",
1813        )
1814        .unwrap();
1815        std::thread::sleep(std::time::Duration::from_millis(20));
1816        std::fs::write(
1817            logs.join("code-kb.log.2026-09-20"),
1818            format!("first\nsecond {home}/repo/src/lib.rs\nthird\n"),
1819        )
1820        .unwrap();
1821        std::thread::sleep(std::time::Duration::from_millis(20));
1822        std::fs::write(logs.join("notes.txt"), "not a log\n").unwrap();
1823
1824        let bundle =
1825            generate_bug_report(&conn, Some(root), Some("Crash"), Some("  It crashed.  "), 2)
1826                .unwrap();
1827
1828        let facts = bundle.index.as_ref().unwrap();
1829        assert_eq!(facts.extractor_version.as_deref(), Some("3.1.1"));
1830        assert_eq!(facts.schema_version.as_deref(), Some("7"));
1831        assert_eq!(facts.index_level.as_deref(), Some("facts"));
1832        assert_eq!(facts.updated_at, None);
1833        assert_eq!((facts.file_count, facts.symbol_count), (2, 1));
1834        assert_eq!(bundle.log_tail, vec!["second ~/repo/src/lib.rs", "third"]);
1835        assert!(
1836            bundle
1837                .markdown_body
1838                .contains("- **Extractor:** 3.1.1 (schema 7, level facts)")
1839        );
1840        assert!(
1841            bundle
1842                .markdown_body
1843                .contains("- **Files / Symbols:** 2 / 1")
1844        );
1845        assert!(
1846            bundle
1847                .markdown_body
1848                .contains("### Description\nIt crashed.\n")
1849        );
1850        assert!(
1851            bundle
1852                .markdown_body
1853                .contains("### Recent Log Lines\n```text\nsecond ~/repo/src/lib.rs\nthird\n```")
1854        );
1855        assert!(!bundle.markdown_body.contains(&home));
1856        assert!(!bundle.github_issue_url.contains("Recent+Log+Lines"));
1857        assert!(bundle.github_issue_url.contains("It+crashed."));
1858
1859        let across_rollover = generate_bug_report(
1860            &conn,
1861            Some(root),
1862            None,
1863            Some(&format!("{home}/x\nline two")),
1864            4,
1865        )
1866        .unwrap();
1867        assert_eq!(
1868            across_rollover.log_tail,
1869            vec!["older two", "first", "second ~/repo/src/lib.rs", "third"]
1870        );
1871        assert!(
1872            across_rollover
1873                .markdown_body
1874                .contains("### Description\n~/x\nline two\n")
1875        );
1876        assert!(!across_rollover.markdown_body.contains(&home));
1877        assert!(!across_rollover.github_issue_url.contains("notes"));
1878
1879        let oversized =
1880            generate_bug_report(&conn, Some(root), None, Some(&"y".repeat(9000)), 0).unwrap();
1881        assert!(oversized.markdown_body.contains(&"y".repeat(9000)));
1882        assert!(oversized.github_issue_url.len() <= MAX_ISSUE_URL_LEN);
1883        assert!(oversized.github_issue_url.contains("too+long+for+a+URL"));
1884        assert!(oversized.github_issue_url.contains("Files+%2F+Symbols"));
1885
1886        let without_index =
1887            generate_bug_report(&conn, Some(Path::new("/nonexistent/ws")), None, None, 0).unwrap();
1888        assert!(without_index.index.is_none());
1889        assert!(without_index.log_tail.is_empty());
1890        assert!(
1891            without_index
1892                .markdown_body
1893                .contains("No `.code-kb/artifact.db`")
1894        );
1895        assert!(without_index.markdown_body.contains("<!-- Please describe"));
1896    }
1897
1898    #[test]
1899    fn test_bug_report_sanitization_and_no_external_exec() {
1900        let temp = crate::safe_tempdir();
1901        let conn = open_telemetry_db_at(temp.path()).unwrap();
1902
1903        let current_home = std::env::var("HOME")
1904            .or_else(|_| std::env::var("USERPROFILE"))
1905            .unwrap_or_else(|_| "/default/home".to_string());
1906
1907        let sensitive_error = format!(
1908            "{}/workspace/secret-repo/src/lib.rs: syntax error | unexpected token | extra line\nsecond line of error | {}",
1909            current_home,
1910            "x".repeat(600), // > 500 chars to test truncation
1911        );
1912
1913        let inv = ToolInvocation {
1914            tool: "replace_symbol_body",
1915            duration_ms: 10,
1916            outcome: "error",
1917            error_message: Some(&sensitive_error),
1918            logical_result_count: None,
1919            bytes_returned: 0,
1920            est_tokens: 0,
1921            est_tokens_saved: 0,
1922            est_tokens_saved_known: true,
1923            reconcile_ms: None,
1924            query_ms: None,
1925        };
1926        // Create a fake malicious julie-extract binary in a .tools directory in workspace
1927        let ws_temp = crate::safe_tempdir();
1928        record_tool_call_conn(&conn, ws_temp.path(), &inv);
1929        let malicious_tools_dir = ws_temp.path().join(".tools");
1930        std::fs::create_dir_all(&malicious_tools_dir).unwrap();
1931        let fake_bin = if cfg!(windows) {
1932            malicious_tools_dir.join("julie-extract.exe")
1933        } else {
1934            malicious_tools_dir.join("julie-extract")
1935        };
1936        std::fs::write(&fake_bin, b"#!/bin/sh\necho malicious 9.9.9\nexit 0\n").unwrap();
1937        #[cfg(unix)]
1938        {
1939            use std::os::unix::fs::PermissionsExt;
1940            std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1941        }
1942
1943        let bundle = generate_bug_report(
1944            &conn,
1945            Some(ws_temp.path()),
1946            Some("Issue with | pipes"),
1947            None,
1948            0,
1949        )
1950        .unwrap();
1951
1952        // 1. Path sanitization verification
1953        if !current_home.is_empty() && current_home != "/" {
1954            assert!(
1955                !bundle.markdown_body.contains(&current_home),
1956                "Home directory must be sanitized to ~"
1957            );
1958            assert!(
1959                bundle.markdown_body.contains("~/workspace/secret-repo"),
1960                "Home directory should be replaced with ~"
1961            );
1962            assert!(
1963                !bundle.github_issue_url.contains(&current_home),
1964                "GitHub URL must not leak home directory"
1965            );
1966        }
1967
1968        // Direct test of custom home path sanitization
1969        let custom_sanitized = sanitize_error_message_with_homes(
1970            "/custom/secret/path/main.rs: err | note\nsecond line",
1971            &["/custom/secret/path".to_string()],
1972        );
1973        assert_eq!(custom_sanitized, "~/main.rs: err \\| note second line");
1974
1975        // 2. Pipe and newline escaping
1976        assert!(
1977            !bundle.markdown_body.contains(" | unexpected token"),
1978            "Pipe characters must be escaped"
1979        );
1980        assert!(
1981            bundle.markdown_body.contains(r" \| unexpected token"),
1982            "Pipe characters must be escaped as \\|"
1983        );
1984        assert!(
1985            !bundle.markdown_body.contains("extra line\nsecond line"),
1986            "Newlines must be sanitized"
1987        );
1988
1989        // 3. Length truncation (max 500 chars)
1990        assert!(
1991            bundle.recent_errors[0].error_message.chars().count() <= 500,
1992            "Error message must be truncated to 500 chars"
1993        );
1994
1995        // 4. No external binary execution verification
1996        assert_ne!(
1997            bundle.julie_extract_version, "malicious 9.9.9",
1998            "Must not execute .tools/julie-extract from workspace"
1999        );
2000        assert_eq!(
2001            bundle.julie_extract_version,
2002            crate::sync::PINNED_JULIE_VERSION,
2003            "Must report pinned version"
2004        );
2005    }
2006
2007    #[test]
2008    fn test_telemetry_summary_symlink_and_macos_private_var_matching() {
2009        let telem_dir = crate::safe_tempdir();
2010        let conn = Connection::open(telem_dir.path().join("telemetry.db")).unwrap();
2011        init_telemetry_db(&conn).unwrap();
2012
2013        // Insert a record using macOS /var/folders path
2014        let raw_var_path = "/var/folders/zz/12345678/T/my_repo";
2015        conn.execute(
2016            "INSERT INTO tool_telemetry VALUES (
2017                't-1', datetime('now'), ?1, 'my_repo', 'lookup_symbol',
2018                12, 'error', 'Failed to find symbol Foo', 0, 0, 100, 25, 0, 0, '0.9.0',
2019                NULL, NULL
2020            )",
2021            params![raw_var_path],
2022        )
2023        .unwrap();
2024
2025        // Query using canonical macOS /private/var/folders path
2026        let filter = TelemetryFilter {
2027            time_window: TimeWindow::AllTime,
2028            workspace_root: Some(PathBuf::from("/private/var/folders/zz/12345678/T/my_repo")),
2029            version: None,
2030        };
2031        let summary = get_telemetry_summary(&conn, &filter).unwrap();
2032        assert_eq!(
2033            summary.total_calls, 1,
2034            "Must match record across /private/var and /var"
2035        );
2036        assert_eq!(summary.recent_errors.len(), 1);
2037        assert!(
2038            summary.recent_errors[0]
2039                .error_message
2040                .contains("Failed to find symbol Foo")
2041        );
2042
2043        // Reverse: insert with /private/var, query with /var
2044        conn.execute(
2045            "INSERT INTO tool_telemetry VALUES (
2046                't-2', datetime('now'), ?1, 'other_repo', 'lookup_symbol',
2047                12, 'error', 'Reverse matching error', 0, 0, 100, 25, 0, 0, '0.9.0',
2048                NULL, NULL
2049            )",
2050            params!["/private/var/folders/zz/99999999/T/other_repo"],
2051        )
2052        .unwrap();
2053
2054        let filter_rev = TelemetryFilter {
2055            time_window: TimeWindow::AllTime,
2056            workspace_root: Some(PathBuf::from("/var/folders/zz/99999999/T/other_repo")),
2057            version: None,
2058        };
2059        let summary_rev = get_telemetry_summary(&conn, &filter_rev).unwrap();
2060        assert_eq!(summary_rev.total_calls, 1);
2061        assert_eq!(summary_rev.recent_errors.len(), 1);
2062        assert!(
2063            summary_rev.recent_errors[0]
2064                .error_message
2065                .contains("Reverse matching error")
2066        );
2067
2068        // Bug report must also match
2069        let bug_report = generate_bug_report(
2070            &conn,
2071            Some(Path::new("/private/var/folders/zz/12345678/T/my_repo")),
2072            Some("test issue"),
2073            None,
2074            0,
2075        )
2076        .unwrap();
2077        assert_eq!(bug_report.recent_errors.len(), 1);
2078        assert!(
2079            bug_report.recent_errors[0]
2080                .error_message
2081                .contains("Failed to find symbol Foo")
2082        );
2083    }
2084
2085    #[test]
2086    fn test_phase_metrics_and_version_filtering() {
2087        let temp = crate::safe_tempdir();
2088        let db_path = temp.path().join("telemetry.db");
2089        let conn = Connection::open(&db_path).unwrap();
2090
2091        // 1. Verify schema upgrade adds reconcile_ms and query_ms
2092        conn.execute_batch(
2093            "CREATE TABLE tool_telemetry (
2094                id TEXT PRIMARY KEY,
2095                timestamp TEXT NOT NULL,
2096                workspace_root TEXT NOT NULL,
2097                workspace_name TEXT NOT NULL,
2098                tool TEXT NOT NULL,
2099                duration_ms INTEGER NOT NULL,
2100                outcome TEXT NOT NULL,
2101                error_message TEXT,
2102                result_count INTEGER NOT NULL DEFAULT 0,
2103                result_count_known INTEGER NOT NULL DEFAULT 0,
2104                bytes_returned INTEGER NOT NULL DEFAULT 0,
2105                est_tokens INTEGER NOT NULL DEFAULT 0,
2106                est_tokens_saved INTEGER NOT NULL DEFAULT 0,
2107                code_kb_version TEXT NOT NULL
2108            );
2109            INSERT INTO tool_telemetry VALUES (
2110                'legacy1', '2026-09-01 12:00:00', '/ws', 'ws', 'lookup_symbol', 10, 'ok', NULL, 1, 1, 50, 12, 0, '1.1.0'
2111            );",
2112        )
2113        .unwrap();
2114
2115        init_telemetry_db(&conn).expect("schema upgrade should succeed on legacy DB");
2116
2117        // Verify legacy row has NULL reconcile_ms and query_ms
2118        let (rec, q): (Option<i64>, Option<i64>) = conn
2119            .query_row(
2120                "SELECT reconcile_ms, query_ms FROM tool_telemetry WHERE id = 'legacy1'",
2121                [],
2122                |row| Ok((row.get(0)?, row.get(1)?)),
2123            )
2124            .unwrap();
2125        assert_eq!(rec, None);
2126        assert_eq!(q, None);
2127
2128        // 2. Insert records with phase metrics and different versions
2129        let inv1 = ToolInvocation {
2130            tool: "lookup_symbol",
2131            duration_ms: 120,
2132            outcome: "ok",
2133            error_message: None,
2134            logical_result_count: Some(1),
2135            bytes_returned: 100,
2136            est_tokens: 25,
2137            est_tokens_saved: 50,
2138            est_tokens_saved_known: true,
2139            reconcile_ms: Some(100),
2140            query_ms: Some(20),
2141        };
2142        // Record with custom version manually for test partitioning
2143        conn.execute(
2144            "INSERT INTO tool_telemetry (
2145                id, timestamp, workspace_root, workspace_name, tool,
2146                duration_ms, outcome, error_message, result_count, result_count_known,
2147                bytes_returned, est_tokens, est_tokens_saved, code_kb_version,
2148                reconcile_ms, query_ms
2149            ) VALUES ('c1', datetime('now'), '/ws', 'ws', ?1, ?2, ?3, NULL, 1, 1, ?4, ?5, ?6, '1.1.2', ?7, ?8)",
2150            params![
2151                inv1.tool,
2152                inv1.duration_ms as i64,
2153                inv1.outcome,
2154                inv1.bytes_returned as i64,
2155                inv1.est_tokens as i64,
2156                inv1.est_tokens_saved as i64,
2157                inv1.reconcile_ms.map(|v| v as i64),
2158                inv1.query_ms.map(|v| v as i64),
2159            ],
2160        ).unwrap();
2161
2162        for (id, dur, q_ms) in [("c2", 5, 5), ("c3", 15, 15), ("c4", 25, 25)] {
2163            conn.execute(
2164                "INSERT INTO tool_telemetry (
2165                    id, timestamp, workspace_root, workspace_name, tool,
2166                    duration_ms, outcome, error_message, result_count, result_count_known,
2167                    bytes_returned, est_tokens, est_tokens_saved, code_kb_version,
2168                    reconcile_ms, query_ms
2169                ) VALUES (?1, datetime('now'), '/ws', 'ws', 'lookup_symbol', ?2, 'ok', NULL, 1, 1, 100, 25, 50, '1.1.3', 0, ?3)",
2170                params![id, dur as i64, q_ms as i64],
2171            ).unwrap();
2172        }
2173
2174        // 3. Test version filtering: "1.1.3"
2175        let filter_v113 = TelemetryFilter {
2176            time_window: TimeWindow::AllTime,
2177            workspace_root: None,
2178            version: Some("1.1.3".to_string()),
2179        };
2180        let summary_v113 = get_telemetry_summary(&conn, &filter_v113).unwrap();
2181        assert_eq!(summary_v113.total_calls, 3);
2182        assert_eq!(summary_v113.tool_stats.len(), 1);
2183        let stat = &summary_v113.tool_stats[0];
2184        assert_eq!(stat.tool, "lookup_symbol");
2185        assert_eq!(stat.count, 3);
2186        assert_eq!(stat.p50_ms, Some(15));
2187        assert_eq!(stat.p95_ms, Some(25));
2188        assert_eq!(stat.avg_reconcile_ms, Some(0));
2189        assert_eq!(stat.avg_query_ms, Some(15));
2190
2191        // 4. Test version filtering: None (all versions including legacy)
2192        let filter_all = TelemetryFilter {
2193            time_window: TimeWindow::AllTime,
2194            workspace_root: None,
2195            version: None,
2196        };
2197        let summary_all = get_telemetry_summary(&conn, &filter_all).unwrap();
2198        assert_eq!(summary_all.total_calls, 5); // legacy1 + c1 + c2 + c3 + c4
2199
2200        // 5. Test record_tool_call_conn persists phase fields
2201        record_tool_call_conn(&conn, Path::new("/ws"), &inv1);
2202        let (last_rec, last_q): (Option<i64>, Option<i64>) = conn
2203            .query_row(
2204                "SELECT reconcile_ms, query_ms FROM tool_telemetry ORDER BY rowid DESC LIMIT 1",
2205                [],
2206                |row| Ok((row.get(0)?, row.get(1)?)),
2207            )
2208            .unwrap();
2209        assert_eq!(last_rec, Some(100));
2210        assert_eq!(last_q, Some(20));
2211    }
2212}