Skip to main content

thndrs_lib/core/context/
mod.rs

1//! Application-owned context discovery and instruction adapters.
2//!
3//! Pure context policy lives in [`thndrs_agent::context`]; this module performs
4//! filesystem discovery and supplies the resulting application data.
5
6pub mod export;
7pub mod instructions;
8
9pub use instructions::{
10    InstructionDiagnostic, InstructionInventory, InstructionSelection, InstructionSeverity, InstructionSnapshot,
11    discover_instructions, select_instructions,
12};
13
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use std::collections::hash_map::DefaultHasher;
19use std::hash::{Hash, Hasher};
20
21fn hash_content(content: &str) -> u64 {
22    let mut hasher = DefaultHasher::new();
23    content.hash(&mut hasher);
24    hasher.finish()
25}
26
27pub fn scope_depth(scope: &str) -> usize {
28    if scope == "." || scope.is_empty() { 0 } else { scope.matches('/').count() + 1 }
29}
30
31/// Maximum bytes read from an AGENTS.md file.
32///
33/// Content beyond this is truncated and the truncation is marked visibly.
34pub const AGENTS_MD_SIZE_CAP: usize = 32_768;
35
36/// A single loaded context source.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct ContextSource {
39    /// Absolute path to the source file.
40    pub path: PathBuf,
41    /// Scope label — `"."` for root, or a relative subtree path.
42    pub scope: String,
43    /// File content (possibly truncated to [`AGENTS_MD_SIZE_CAP`]).
44    pub content: String,
45    /// Stable hash of the full original content (before truncation).
46    pub content_hash: u64,
47    /// Whether the content was truncated to fit the size cap.
48    pub truncated: bool,
49    /// Original byte count of the file (before truncation).
50    pub byte_count: usize,
51}
52
53impl ContextSource {
54    /// Render a compact summary for the transcript status line.
55    pub fn summary(&self) -> String {
56        let path_display = self.path.display().to_string();
57        match self.truncated {
58            true => format!("loaded {path_display} (truncated, {} bytes)", self.byte_count),
59            false => format!("loaded {path_display}"),
60        }
61    }
62}
63
64/// Discover the workspace root from `cwd`. Prefers the git top-level directory
65/// when available; falls back to the canonicalized `cwd`.
66///
67/// Uses `git rev-parse --show-toplevel` with [`std::process::Command`] (argv
68/// array, never shell strings).
69///
70/// If git is unavailable or `cwd` is not inside a repo, returns a
71/// fully-qualified `cwd` when the path can be canonicalized.
72pub fn discover_workspace_root(cwd: &Path) -> PathBuf {
73    match Command::new("git")
74        .args(["rev-parse", "--show-toplevel"])
75        .current_dir(cwd)
76        .output()
77    {
78        Ok(output) if output.status.success() => {
79            let stdout = String::from_utf8_lossy(&output.stdout);
80            let root = stdout.trim();
81            let root = if root.is_empty() { cwd.to_path_buf() } else { PathBuf::from(root) };
82            root.canonicalize().unwrap_or(root)
83        }
84        _ => cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()),
85    }
86}
87
88/// Load root `AGENTS.md` from the workspace root, if present.
89///
90/// Returns `None` when the file does not exist.
91///
92/// Enforces [`AGENTS_MD_SIZE_CAP`]: content beyond the cap is truncated and
93/// `truncated` is set to `true`.
94///
95/// Computes a content hash of the full file content before truncation.
96pub fn load_agents_md(workspace_root: &Path) -> Option<ContextSource> {
97    let path = workspace_root.join("AGENTS.md");
98    let metadata = fs::metadata(&path).ok()?;
99    let byte_count = metadata.len() as usize;
100    let content = fs::read_to_string(&path).ok()?;
101    let content_hash = hash_content(&content);
102
103    let (content, truncated) = if byte_count > AGENTS_MD_SIZE_CAP {
104        let mut capped = content.into_bytes();
105        capped.truncate(AGENTS_MD_SIZE_CAP);
106        (
107            trim_to_char_boundary(&String::from_utf8_lossy(&capped), AGENTS_MD_SIZE_CAP),
108            true,
109        )
110    } else {
111        (content, false)
112    };
113
114    Some(ContextSource { path, scope: String::from("."), content, content_hash, truncated, byte_count })
115}
116
117/// Trim a string to at most `max_bytes` bytes, ensuring we end on a UTF-8 char boundary.
118pub fn trim_to_char_boundary(s: &str, max_bytes: usize) -> String {
119    if s.len() <= max_bytes {
120        return s.to_string();
121    }
122    let mut end = max_bytes;
123    while end > 0 && !s.is_char_boundary(end) {
124        end -= 1;
125    }
126    s[..end].to_string()
127}