1use std::collections::{HashMap, HashSet};
38use std::fs;
39use std::path::{Path, PathBuf};
40
41use serde::{Deserialize, Serialize};
42
43use crate::Config;
44use crate::config::DEFAULT_LLM_MODEL;
45use crate::error::Result;
46
47const TRACKED_ENV_VARS: &[&str] = &[
49 "AGENTSEC_HOME",
50 "HOME",
51 "ANTHROPIC_API_KEY",
52 "AGENTSEC_LLM_MODEL",
53 "AGENTSEC_DOTENV",
54];
55
56pub const VERSION: &str = env!("CARGO_PKG_VERSION");
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct InfoReport {
64 pub version: String,
66 pub paths: PathsInfo,
68 pub env: Vec<EnvVarStatus>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PathsInfo {
75 pub home: PathBuf,
76 pub user_home: PathBuf,
77 pub snapshots: PathBuf,
78 pub scans: PathBuf,
79 pub web_log: PathBuf,
80 pub paste_log: PathBuf,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct EnvVarStatus {
86 pub key: String,
87 pub source: EnvSource,
88 pub value_display: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub enum EnvSource {
95 Process,
97 DotenvFile { path: PathBuf, line: usize },
102 DotenvFileShadowed { path: PathBuf, line: usize },
106 Default(String),
108 Unset,
110}
111
112pub fn info(cfg: &Config) -> InfoReport {
118 let paths = PathsInfo {
119 home: cfg.paths.home.clone(),
120 user_home: cfg.paths.user_home.clone(),
121 snapshots: cfg.paths.snapshots(),
122 scans: cfg.paths.scans(),
123 web_log: cfg.paths.web_log(),
124 paste_log: cfg.paths.paste_log(),
125 };
126
127 let dotenv_keys = cfg
128 .dotenv_path
129 .as_ref()
130 .and_then(|p| parse_dotenv(p).ok())
131 .unwrap_or_default();
132
133 let env = TRACKED_ENV_VARS
134 .iter()
135 .map(|key| classify_env_var(key, &dotenv_keys, cfg.dotenv_path.as_deref()))
136 .collect();
137
138 InfoReport {
139 version: VERSION.to_string(),
140 paths,
141 env,
142 }
143}
144
145type DotenvKeys = HashMap<String, usize>;
147
148fn parse_dotenv(path: &Path) -> Result<DotenvKeys> {
152 let body = fs::read_to_string(path)?;
153 let mut out = DotenvKeys::new();
154 for (idx, raw) in body.lines().enumerate() {
155 let trimmed = raw.trim_start();
156 if trimmed.is_empty() || trimmed.starts_with('#') {
157 continue;
158 }
159 let after_export = trimmed.strip_prefix("export ").unwrap_or(trimmed);
161 let Some(eq_idx) = after_export.find('=') else {
162 continue;
163 };
164 let key = after_export[..eq_idx].trim().to_string();
165 if !is_valid_env_key(&key) {
166 continue;
167 }
168 out.entry(key).or_insert(idx + 1);
170 }
171 Ok(out)
172}
173
174fn is_valid_env_key(s: &str) -> bool {
175 !s.is_empty()
176 && s.chars()
177 .next()
178 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
179 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
180}
181
182fn classify_env_var(
183 key: &str,
184 dotenv_keys: &DotenvKeys,
185 dotenv_path: Option<&Path>,
186) -> EnvVarStatus {
187 let in_dotenv_line = dotenv_keys.get(key).copied();
188 let process_value = std::env::var(key).ok();
189
190 let (source, value_display) = match (process_value, in_dotenv_line, dotenv_path) {
191 (Some(v), Some(line), Some(path)) => {
192 (
203 EnvSource::DotenvFile {
204 path: path.to_path_buf(),
205 line,
206 },
207 Some(display_value(key, &v)),
208 )
209 }
210 (Some(v), None, _) | (Some(v), Some(_), None) => {
215 (EnvSource::Process, Some(display_value(key, &v)))
216 }
217 (None, Some(line), Some(path)) => (
218 EnvSource::DotenvFile {
219 path: path.to_path_buf(),
220 line,
221 },
222 None,
223 ),
224 (None, None, _) | (None, Some(_), None) => match default_for(key) {
225 Some(d) => (EnvSource::Default(d.clone()), Some(d)),
226 None => (EnvSource::Unset, None),
227 },
228 };
229
230 EnvVarStatus {
231 key: key.to_string(),
232 source,
233 value_display,
234 }
235}
236
237fn default_for(key: &str) -> Option<String> {
238 match key {
239 "AGENTSEC_LLM_MODEL" => Some(DEFAULT_LLM_MODEL.to_string()),
240 _ => None,
241 }
242}
243
244fn display_value(key: &str, raw: &str) -> String {
245 if is_secret_key(key) {
246 redact(raw)
247 } else {
248 raw.to_string()
249 }
250}
251
252fn is_secret_key(key: &str) -> bool {
253 matches!(key, "ANTHROPIC_API_KEY")
254}
255
256fn redact(value: &str) -> String {
257 if value.is_empty() {
258 return "<empty>".to_string();
259 }
260 let chars: Vec<char> = value.chars().collect();
261 if chars.len() <= 8 {
262 return "***".to_string();
263 }
264 let tail: String = chars[chars.len().saturating_sub(4)..].iter().collect();
265 format!("***{tail} (len={})", chars.len())
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct StorageItem {
281 pub kind: String,
282 pub path: PathBuf,
283 pub count: usize,
284 pub size_bytes: u64,
285 pub latest: Option<String>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct StatusReport {
291 pub snapshots_count: usize,
292 pub latest_snapshot: Option<String>,
293 pub paste_log_count: usize,
294 pub web_log_count: usize,
295 pub plain_mode_active: bool,
296 pub plain_mode_entries: usize,
297 pub registry_entries: usize,
298 #[serde(default)]
301 pub storage: Vec<StorageItem>,
302}
303
304pub fn status(cfg: &Config) -> StatusReport {
309 use crate::plain_mode;
310 use crate::registry::Registry;
311
312 let snapshots_dir = cfg.paths.snapshots();
313 let (snapshots_count, latest_snapshot) = count_and_latest(&snapshots_dir);
314 let paste_log_count = count_files(&cfg.paths.paste_log());
315 let web_log_count = count_files(&cfg.paths.web_log());
316
317 let plain = plain_mode::status(&cfg.paths).unwrap_or_else(|_| plain_mode::PlainStatus {
318 active: false,
319 entries: Vec::new(),
320 ledger_path: cfg.paths.home.join(plain_mode::PLAIN_LEDGER_FILENAME),
321 });
322
323 let registry_entries = Registry::load_or_builtin(&cfg.paths).len();
324
325 let storage = collect_storage(cfg);
326
327 StatusReport {
328 snapshots_count,
329 latest_snapshot,
330 paste_log_count,
331 web_log_count,
332 plain_mode_active: plain.active,
333 plain_mode_entries: plain.entries.len(),
334 registry_entries,
335 storage,
336 }
337}
338
339fn collect_storage(cfg: &Config) -> Vec<StorageItem> {
345 let home = &cfg.paths.home;
346 vec![
347 dir_storage("snapshots", &cfg.paths.snapshots()),
348 dir_storage("paste_log", &cfg.paths.paste_log()),
349 dir_storage("web_log", &cfg.paths.web_log()),
350 dir_storage("scans", &cfg.paths.scans()),
351 file_storage(
352 "plain ledger",
353 home.join(crate::plain_mode::PLAIN_LEDGER_FILENAME),
354 ),
355 file_storage("registry cache", home.join("registry.json")),
356 file_storage("registry local", home.join("registry-local.json")),
357 ]
358}
359
360fn dir_storage(kind: &str, path: &Path) -> StorageItem {
361 let (count, size_bytes, latest) = match fs::read_dir(path) {
362 Err(_) => (0, 0, None),
363 Ok(entries) => {
364 let mut names: Vec<String> = Vec::new();
365 let mut total: u64 = 0;
366 for e in entries.flatten() {
367 if let Ok(md) = e.metadata()
368 && md.is_file()
369 {
370 total = total.saturating_add(md.len());
371 if let Some(name) = e.file_name().to_str() {
372 names.push(name.to_string());
373 }
374 }
375 }
376 let count = names.len();
377 names.sort();
378 (count, total, names.last().cloned())
379 }
380 };
381 StorageItem {
382 kind: kind.to_string(),
383 path: path.to_path_buf(),
384 count,
385 size_bytes,
386 latest,
387 }
388}
389
390fn file_storage(kind: &str, path: PathBuf) -> StorageItem {
391 let (count, size_bytes, latest) = match fs::metadata(&path) {
392 Ok(md) if md.is_file() => {
393 let size = md.len();
394 let latest = md.modified().ok().and_then(|t| {
395 t.duration_since(std::time::UNIX_EPOCH)
396 .ok()
397 .map(|d| format!("epoch={}", d.as_secs()))
398 });
399 (1, size, latest)
400 }
401 _ => (0, 0, None),
402 };
403 StorageItem {
404 kind: kind.to_string(),
405 path,
406 count,
407 size_bytes,
408 latest,
409 }
410}
411
412pub fn format_size(bytes: u64) -> String {
415 const KB: u64 = 1024;
416 const MB: u64 = KB * 1024;
417 const GB: u64 = MB * 1024;
418 let (unit, scale) = if bytes >= GB {
419 ("GB", GB)
420 } else if bytes >= MB {
421 ("MB", MB)
422 } else if bytes >= KB {
423 ("KB", KB)
424 } else {
425 return format!("{bytes} B");
426 };
427 let whole = bytes / scale;
428 let tenth = (bytes % scale) * 10 / scale;
429 format!("{whole}.{tenth} {unit}")
430}
431
432fn count_files(dir: &Path) -> usize {
433 fs::read_dir(dir)
434 .map(std::iter::Iterator::count)
435 .unwrap_or(0)
436}
437
438fn count_and_latest(dir: &Path) -> (usize, Option<String>) {
439 let Ok(entries) = fs::read_dir(dir) else {
440 return (0, None);
441 };
442 let mut names: Vec<String> = entries
443 .filter_map(std::result::Result::ok)
444 .filter_map(|e| {
445 let p = e.path();
446 if p.extension()
447 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
448 {
449 e.file_name().to_str().map(std::string::ToString::to_string)
450 } else {
451 None
452 }
453 })
454 .collect();
455 let count = names.len();
456 names.sort();
457 (count, names.last().cloned())
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct RecentActivityReport {
465 pub paste: Vec<AuditTail>,
466 pub web: Vec<AuditTail>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct AuditTail {
472 pub filename: String,
474 pub size: u64,
476 pub excerpt: String,
480}
481
482pub fn recent_activity(cfg: &Config, n: usize) -> RecentActivityReport {
485 RecentActivityReport {
486 paste: tail_dir(&cfg.paths.paste_log(), n),
487 web: tail_dir(&cfg.paths.web_log(), n),
488 }
489}
490
491const AUDIT_EXCERPT_CHARS: usize = 200;
492const EXCERPT_CONTEXT_LINES: usize = 2;
494
495fn tail_dir(dir: &Path, n: usize) -> Vec<AuditTail> {
496 let Ok(entries) = fs::read_dir(dir) else {
497 return Vec::new();
498 };
499 let mut files: Vec<(String, PathBuf)> = entries
500 .filter_map(std::result::Result::ok)
501 .filter_map(|e| {
502 let p = e.path();
503 if !p
504 .extension()
505 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
506 {
507 return None;
508 }
509 let name = e.file_name().to_str()?.to_string();
510 Some((name, e.path()))
511 })
512 .collect();
513 files.sort_by(|(a, _), (b, _)| a.cmp(b));
514 files
515 .into_iter()
516 .rev()
517 .take(n)
518 .map(|(name, path)| AuditTail {
519 filename: name,
520 size: fs::metadata(&path).map(|m| m.len()).unwrap_or(0),
521 excerpt: read_excerpt(&path),
522 })
523 .collect()
524}
525
526fn read_excerpt(path: &Path) -> String {
536 let Ok(body) = fs::read_to_string(path) else {
537 return String::new();
538 };
539 let lines: Vec<&str> = body.lines().collect();
540 if lines.is_empty() {
541 return String::new();
542 }
543 let anchor = lines
545 .iter()
546 .enumerate()
547 .filter(|(_, l)| {
548 let t = l.trim();
549 t != "{" && t != "}" && !t.is_empty()
550 })
551 .max_by_key(|(_, l)| l.len())
552 .map_or(0, |(i, _)| i);
553 let start = anchor.saturating_sub(EXCERPT_CONTEXT_LINES);
554 let end = (anchor + EXCERPT_CONTEXT_LINES + 1).min(lines.len());
555 let joined = lines[start..end].join("\n");
556 if joined.chars().count() <= AUDIT_EXCERPT_CHARS {
557 joined
558 } else {
559 let truncated: String = joined.chars().take(AUDIT_EXCERPT_CHARS).collect();
560 format!("{truncated}…")
561 }
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct DoctorReport {
569 pub checks: Vec<DoctorCheck>,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
574pub struct DoctorCheck {
575 pub name: String,
576 pub status: DoctorStatus,
577 pub message: String,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
582pub enum DoctorStatus {
583 Pass,
585 Warn,
589 Fail,
591}
592
593pub fn doctor(cfg: &Config) -> DoctorReport {
607 let mut checks = Vec::new();
608
609 checks.push(DoctorCheck {
610 name: "binary version".into(),
611 status: DoctorStatus::Pass,
612 message: format!("agentsec-core {VERSION}"),
613 });
614
615 checks.push(check_home_writable(cfg));
616 checks.extend(check_subdirs(cfg));
617 checks.push(check_dotenv(cfg));
618 checks.push(check_api_key(cfg));
619 checks.extend(check_mcp_registration(cfg));
620 checks.extend(check_hook_wiring(cfg));
621
622 DoctorReport { checks }
623}
624
625fn check_home_writable(cfg: &Config) -> DoctorCheck {
626 let home = &cfg.paths.home;
627 if let Err(e) = fs::create_dir_all(home) {
628 return DoctorCheck {
629 name: "home dir".into(),
630 status: DoctorStatus::Fail,
631 message: format!("cannot create {}: {e}", home.display()),
632 };
633 }
634 let probe = home.join(".agentsec-doctor-probe");
635 match fs::write(&probe, b"probe") {
636 Ok(()) => {
637 let _ = fs::remove_file(&probe);
638 DoctorCheck {
639 name: "home dir".into(),
640 status: DoctorStatus::Pass,
641 message: format!("{} is writable", home.display()),
642 }
643 }
644 Err(e) => DoctorCheck {
645 name: "home dir".into(),
646 status: DoctorStatus::Fail,
647 message: format!("{} is not writable: {e}", home.display()),
648 },
649 }
650}
651
652fn check_subdirs(cfg: &Config) -> Vec<DoctorCheck> {
653 let p = &cfg.paths;
654 vec![
655 check_subdir_creatable("snapshots dir", &p.snapshots()),
656 check_subdir_creatable("scans dir", &p.scans()),
657 check_subdir_creatable("web_log dir", &p.web_log()),
658 check_subdir_creatable("paste_log dir", &p.paste_log()),
659 ]
660}
661
662fn check_subdir_creatable(name: &str, path: &Path) -> DoctorCheck {
663 match fs::create_dir_all(path) {
664 Ok(()) => DoctorCheck {
665 name: name.to_string(),
666 status: DoctorStatus::Pass,
667 message: format!("{} present", path.display()),
668 },
669 Err(e) => DoctorCheck {
670 name: name.to_string(),
671 status: DoctorStatus::Fail,
672 message: format!("cannot create {}: {e}", path.display()),
673 },
674 }
675}
676
677fn check_dotenv(cfg: &Config) -> DoctorCheck {
678 match &cfg.dotenv_path {
679 Some(p) if p.exists() => DoctorCheck {
680 name: "dotenv".into(),
681 status: DoctorStatus::Pass,
682 message: format!("loaded {}", p.display()),
683 },
684 Some(p) => DoctorCheck {
685 name: "dotenv".into(),
686 status: DoctorStatus::Warn,
687 message: format!("recorded dotenv path missing on disk: {}", p.display()),
688 },
689 None => DoctorCheck {
690 name: "dotenv".into(),
691 status: DoctorStatus::Warn,
692 message: "no .env loaded (process env only)".into(),
693 },
694 }
695}
696
697fn check_api_key(cfg: &Config) -> DoctorCheck {
698 if cfg.llm.api_key.is_some() {
699 DoctorCheck {
700 name: "anthropic api key".into(),
701 status: DoctorStatus::Pass,
702 message: "set; semantic sanitize layer enabled".into(),
703 }
704 } else {
705 DoctorCheck {
706 name: "anthropic api key".into(),
707 status: DoctorStatus::Warn,
708 message: "unset; semantic sanitize layer is no-op (regex layer still active)".into(),
709 }
710 }
711}
712
713fn check_mcp_registration(cfg: &Config) -> Vec<DoctorCheck> {
716 let path = cfg.paths.user_home.join(".claude.json");
717 if !path.exists() {
718 return vec![DoctorCheck {
719 name: "mcp registration".into(),
720 status: DoctorStatus::Warn,
721 message: format!(
722 "{} not found; cannot verify Claude Code MCP wiring",
723 path.display()
724 ),
725 }];
726 }
727 let body = match fs::read_to_string(&path) {
728 Ok(b) => b,
729 Err(e) => {
730 return vec![DoctorCheck {
731 name: "mcp registration".into(),
732 status: DoctorStatus::Warn,
733 message: format!("cannot read {}: {e}", path.display()),
734 }];
735 }
736 };
737 let json: serde_json::Value = match serde_json::from_str(&body) {
738 Ok(v) => v,
739 Err(e) => {
740 return vec![DoctorCheck {
741 name: "mcp registration".into(),
742 status: DoctorStatus::Warn,
743 message: format!("{} is not valid JSON: {e}", path.display()),
744 }];
745 }
746 };
747
748 let mut sites = Vec::new();
749 if json
750 .get("mcpServers")
751 .and_then(|v| v.get("agentsec"))
752 .is_some()
753 {
754 sites.push("user-scope (.claude.json:mcpServers.agentsec)".to_string());
755 }
756 if let Some(projects) = json.get("projects").and_then(|v| v.as_object()) {
757 for (proj, val) in projects {
758 if val
759 .get("mcpServers")
760 .and_then(|v| v.get("agentsec"))
761 .is_some()
762 {
763 sites.push(format!("project-scope ({proj})"));
764 }
765 }
766 }
767
768 if sites.is_empty() {
769 vec![DoctorCheck {
770 name: "mcp registration".into(),
771 status: DoctorStatus::Fail,
772 message: "agentsec not registered anywhere in .claude.json; run `claude mcp add agentsec -s user -- agentsec mcp`".into(),
773 }]
774 } else {
775 sites
776 .into_iter()
777 .map(|site| DoctorCheck {
778 name: "mcp registration".into(),
779 status: DoctorStatus::Pass,
780 message: format!("registered at {site}"),
781 })
782 .collect()
783 }
784}
785
786fn check_hook_wiring(cfg: &Config) -> Vec<DoctorCheck> {
790 let path = cfg.paths.user_home.join(".claude/settings.json");
791 if !path.exists() {
792 return vec![DoctorCheck {
793 name: "hook wiring".into(),
794 status: DoctorStatus::Warn,
795 message: format!(
796 "{} not found; cannot verify Claude Code hook wiring",
797 path.display()
798 ),
799 }];
800 }
801 let body = match fs::read_to_string(&path) {
802 Ok(b) => b,
803 Err(e) => {
804 return vec![DoctorCheck {
805 name: "hook wiring".into(),
806 status: DoctorStatus::Warn,
807 message: format!("cannot read {}: {e}", path.display()),
808 }];
809 }
810 };
811 let json: serde_json::Value = match serde_json::from_str(&body) {
812 Ok(v) => v,
813 Err(e) => {
814 return vec![DoctorCheck {
815 name: "hook wiring".into(),
816 status: DoctorStatus::Warn,
817 message: format!("{} is not valid JSON: {e}", path.display()),
818 }];
819 }
820 };
821
822 let wired = collect_wired_hooks(&json);
823 let expected = ["user-prompt-submit", "session-start"];
824 expected
825 .iter()
826 .map(|name| {
827 let full = format!("agentsec hook {name}");
828 if wired.contains((*name).to_string().as_str()) {
829 DoctorCheck {
830 name: format!("hook: {name}"),
831 status: DoctorStatus::Pass,
832 message: format!("`{full}` is wired in settings.json"),
833 }
834 } else {
835 DoctorCheck {
836 name: format!("hook: {name}"),
837 status: DoctorStatus::Warn,
838 message: format!(
839 "`{full}` not wired; add it under hooks.{} in {}",
840 hook_key_for(name),
841 path.display()
842 ),
843 }
844 }
845 })
846 .collect()
847}
848
849pub(crate) fn collect_wired_hooks(json: &serde_json::Value) -> HashSet<String> {
852 let mut out = HashSet::new();
853 let Some(hooks) = json.get("hooks").and_then(|v| v.as_object()) else {
854 return out;
855 };
856 for matcher_list in hooks.values() {
857 let Some(matchers) = matcher_list.as_array() else {
858 continue;
859 };
860 for matcher in matchers {
861 let Some(inner) = matcher.get("hooks").and_then(|v| v.as_array()) else {
862 continue;
863 };
864 for hook in inner {
865 if let Some(cmd) = hook.get("command").and_then(|v| v.as_str())
866 && let Some(name) = extract_hook_name(cmd)
867 {
868 out.insert(name);
869 }
870 }
871 }
872 }
873 out
874}
875
876pub(crate) fn extract_hook_name(cmd: &str) -> Option<String> {
877 let needle = "agentsec hook ";
880 let idx = cmd.find(needle)?;
881 let after = &cmd[idx + needle.len()..];
882 let name = after.split_whitespace().next()?;
883 Some(name.to_string())
884}
885
886pub(crate) fn hook_key_for(name: &str) -> &'static str {
887 match name {
888 "user-prompt-submit" => "UserPromptSubmit",
889 "session-start" => "SessionStart",
890 _ => "(unknown)",
891 }
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897 use crate::Paths;
898
899 fn cfg_for(tmp: &tempfile::TempDir, dotenv: Option<PathBuf>) -> Config {
900 Config {
901 paths: Paths {
902 home: tmp.path().to_path_buf(),
903 user_home: tmp.path().to_path_buf(),
904 },
905 llm: crate::LlmConfig {
906 api_key: None,
907 model: DEFAULT_LLM_MODEL.into(),
908 },
909 paste: crate::config::PasteConfig::default(),
910 web: crate::config::WebConfig::default(),
911 dotenv_path: dotenv,
912 }
913 }
914
915 #[test]
916 fn parse_dotenv_picks_up_keys_with_line_numbers() {
917 let tmp = tempfile::tempdir().unwrap();
918 let p = tmp.path().join(".env");
919 fs::write(
920 &p,
921 "# comment\nFOO=bar\nexport BAZ=qux\n\n# another\nQUUX=zap\n",
922 )
923 .unwrap();
924 let keys = parse_dotenv(&p).unwrap();
925 assert_eq!(keys.get("FOO"), Some(&2));
926 assert_eq!(keys.get("BAZ"), Some(&3));
927 assert_eq!(keys.get("QUUX"), Some(&6));
928 }
929
930 #[test]
931 fn redact_short_secret_is_three_stars() {
932 assert_eq!(redact("short"), "***");
933 }
934
935 #[test]
936 fn redact_long_secret_shows_last_four_and_length() {
937 let out = redact("sk-ant-12345-very-long-api-key-zzzz");
938 assert!(out.starts_with("***"));
939 assert!(out.contains("zzzz"));
940 assert!(out.contains("len="));
941 }
942
943 #[test]
944 fn doctor_runs_without_panicking_on_empty_tempdir() {
945 let tmp = tempfile::tempdir().unwrap();
946 let cfg = cfg_for(&tmp, None);
947 let report = doctor(&cfg);
948 assert!(!report.checks.is_empty());
949 let home_check = report.checks.iter().find(|c| c.name == "home dir").unwrap();
951 assert_eq!(home_check.status, DoctorStatus::Pass);
952 }
953
954 #[test]
955 fn status_reports_zero_for_empty_tempdir() {
956 let tmp = tempfile::tempdir().unwrap();
957 let cfg = cfg_for(&tmp, None);
958 let s = status(&cfg);
959 assert_eq!(s.snapshots_count, 0);
960 assert_eq!(s.paste_log_count, 0);
961 assert_eq!(s.web_log_count, 0);
962 assert!(!s.plain_mode_active);
963 assert!(s.registry_entries > 0);
965 }
966
967 #[test]
968 fn status_storage_section_has_all_kinds() {
969 let tmp = tempfile::tempdir().unwrap();
970 let cfg = cfg_for(&tmp, None);
971 let s = status(&cfg);
972 let kinds: Vec<&str> = s.storage.iter().map(|i| i.kind.as_str()).collect();
975 assert!(kinds.contains(&"snapshots"));
976 assert!(kinds.contains(&"paste_log"));
977 assert!(kinds.contains(&"web_log"));
978 assert!(kinds.contains(&"scans"));
979 assert!(kinds.contains(&"plain ledger"));
980 assert!(kinds.contains(&"registry cache"));
981 assert!(kinds.contains(&"registry local"));
982 for item in &s.storage {
984 assert_eq!(
985 item.count, 0,
986 "kind {} count must be 0 on empty home",
987 item.kind
988 );
989 assert_eq!(
990 item.size_bytes, 0,
991 "kind {} size must be 0 on empty home",
992 item.kind
993 );
994 assert!(
996 item.path.is_absolute(),
997 "kind {} path must be absolute",
998 item.kind
999 );
1000 }
1001 }
1002
1003 #[test]
1004 fn status_storage_picks_up_paste_log_files() {
1005 use std::fs;
1006 let tmp = tempfile::tempdir().unwrap();
1007 let cfg = cfg_for(&tmp, None);
1008 let paste_dir = cfg.paths.paste_log();
1009 fs::create_dir_all(&paste_dir).unwrap();
1010 fs::write(paste_dir.join("aaa.json"), "{}").unwrap();
1011 fs::write(paste_dir.join("bbb.json"), "{\"x\":1}").unwrap();
1012 let s = status(&cfg);
1013 let paste = s.storage.iter().find(|i| i.kind == "paste_log").unwrap();
1014 assert_eq!(paste.count, 2);
1015 assert!(
1016 paste.size_bytes >= 9,
1017 "two small JSON files should sum to ≥9 bytes"
1018 );
1019 assert_eq!(paste.latest.as_deref(), Some("bbb.json"));
1020 }
1021
1022 #[test]
1023 fn format_size_reports_units() {
1024 use crate::diagnostics::format_size;
1025 assert_eq!(format_size(0), "0 B");
1026 assert_eq!(format_size(512), "512 B");
1027 assert_eq!(format_size(1024), "1.0 KB");
1028 assert_eq!(format_size(1536), "1.5 KB");
1029 assert_eq!(format_size(1024 * 1024), "1.0 MB");
1030 assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
1031 }
1032
1033 #[test]
1034 fn info_reports_env_with_default_for_unset_model() {
1035 let tmp = tempfile::tempdir().unwrap();
1036 let cfg = cfg_for(&tmp, None);
1037 let report = info(&cfg);
1041 let model = report
1042 .env
1043 .iter()
1044 .find(|e| e.key == "AGENTSEC_LLM_MODEL")
1045 .unwrap();
1046 match &model.source {
1049 EnvSource::Default(v) => assert_eq!(v, DEFAULT_LLM_MODEL),
1050 EnvSource::Process | EnvSource::DotenvFile { .. } => { }
1051 other => panic!("unexpected source for AGENTSEC_LLM_MODEL: {other:?}"),
1052 }
1053 }
1054
1055 #[test]
1056 fn doctor_flags_missing_claude_json_with_warn_not_fail() {
1057 let tmp = tempfile::tempdir().unwrap();
1058 let cfg = cfg_for(&tmp, None);
1059 let report = doctor(&cfg);
1060 let mcp = report
1061 .checks
1062 .iter()
1063 .find(|c| c.name == "mcp registration")
1064 .unwrap();
1065 assert_eq!(mcp.status, DoctorStatus::Warn);
1067 }
1068
1069 #[test]
1070 fn extract_hook_name_handles_full_path() {
1071 assert_eq!(
1072 extract_hook_name("/Users/x/.cargo/bin/agentsec hook user-prompt-submit"),
1073 Some("user-prompt-submit".to_string())
1074 );
1075 assert_eq!(
1076 extract_hook_name("agentsec hook session-start"),
1077 Some("session-start".to_string())
1078 );
1079 assert_eq!(extract_hook_name("python3 something.py"), None);
1080 }
1081}