Skip to main content

chub_core/team/
context.rs

1use std::fs;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::team::project::project_chub_dir;
7
8/// A custom project context document.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ContextDoc {
11    pub name: String,
12    pub description: String,
13    #[serde(default)]
14    pub tags: Vec<String>,
15    /// Filename (relative to .chub/context/)
16    pub file: String,
17}
18
19/// Frontmatter for a context doc.
20#[derive(Debug, Clone, Default, Deserialize)]
21struct ContextFrontmatter {
22    #[serde(default)]
23    name: Option<String>,
24    #[serde(default)]
25    description: Option<String>,
26    #[serde(default)]
27    tags: Option<String>,
28}
29
30fn context_dir() -> Option<PathBuf> {
31    project_chub_dir().map(|d| d.join("context"))
32}
33
34/// Parse frontmatter from a markdown context doc.
35fn parse_context_frontmatter(content: &str) -> (ContextFrontmatter, String) {
36    if !content.starts_with("---") {
37        return (ContextFrontmatter::default(), content.to_string());
38    }
39    let rest = &content[3..];
40    if let Some(end) = rest.find("\n---") {
41        let yaml_str = &rest[..end];
42        let body = &rest[end + 4..];
43        let fm: ContextFrontmatter = serde_yaml::from_str(yaml_str).unwrap_or_default();
44        (fm, body.trim_start_matches('\n').to_string())
45    } else {
46        (ContextFrontmatter::default(), content.to_string())
47    }
48}
49
50/// Discover all context docs in `.chub/context/`.
51pub fn discover_context_docs() -> Vec<ContextDoc> {
52    let dir = match context_dir() {
53        Some(d) if d.exists() => d,
54        _ => return vec![],
55    };
56
57    let mut docs = Vec::new();
58
59    let entries = match fs::read_dir(&dir) {
60        Ok(e) => e,
61        Err(_) => return vec![],
62    };
63
64    for entry in entries.filter_map(|e| e.ok()) {
65        let path = entry.path();
66        if !path
67            .extension()
68            .map(|e| e == "md" || e == "markdown")
69            .unwrap_or(false)
70        {
71            continue;
72        }
73
74        let filename = path
75            .file_name()
76            .unwrap_or_default()
77            .to_string_lossy()
78            .to_string();
79        let stem = path
80            .file_stem()
81            .unwrap_or_default()
82            .to_string_lossy()
83            .to_string();
84
85        let content = match fs::read_to_string(&path) {
86            Ok(c) => c,
87            Err(_) => continue,
88        };
89
90        let (fm, _body) = parse_context_frontmatter(&content);
91        let name = fm.name.unwrap_or_else(|| stem.clone());
92        let description = fm.description.unwrap_or_default();
93        let tags = fm
94            .tags
95            .map(|t| {
96                t.split(',')
97                    .map(|s| s.trim().to_string())
98                    .filter(|s| !s.is_empty())
99                    .collect()
100            })
101            .unwrap_or_default();
102
103        docs.push(ContextDoc {
104            name,
105            description,
106            tags,
107            file: filename,
108        });
109    }
110
111    docs.sort_by(|a, b| a.name.cmp(&b.name));
112    docs
113}
114
115/// Get a specific context doc by name (stem or name field).
116pub fn get_context_doc(name: &str) -> Option<(ContextDoc, String)> {
117    let dir = context_dir()?;
118
119    // Reject names with path traversal attempts
120    if name.contains("..") || name.contains('/') || name.contains('\\') || name.contains('\0') {
121        return None;
122    }
123
124    // Try exact filename first
125    let md_path = dir.join(format!("{}.md", name));
126
127    // Verify the resolved path stays within the context directory
128    if crate::util::validate_path_within(&dir, &md_path, "context doc").is_err() {
129        return None;
130    }
131
132    if md_path.exists() {
133        let content = fs::read_to_string(&md_path).ok()?;
134        let (fm, _body) = parse_context_frontmatter(&content);
135        let doc = ContextDoc {
136            name: fm.name.unwrap_or_else(|| name.to_string()),
137            description: fm.description.unwrap_or_default(),
138            tags: fm
139                .tags
140                .map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
141                .unwrap_or_default(),
142            file: format!("{}.md", name),
143        };
144        return Some((doc, content));
145    }
146
147    // Search by name field (discover_context_docs only reads files directly in the dir)
148    for doc in discover_context_docs() {
149        if doc.name.to_lowercase() == name.to_lowercase() {
150            let full_path = dir.join(&doc.file);
151            // doc.file comes from read_dir so it's already a direct child — verify anyway
152            if crate::util::validate_path_within(&dir, &full_path, "context doc").is_err() {
153                continue;
154            }
155            let content = fs::read_to_string(&full_path).ok()?;
156            return Some((doc, content));
157        }
158    }
159
160    None
161}
162
163/// List context docs (name and description only).
164pub fn list_context_docs() -> Vec<ContextDoc> {
165    discover_context_docs()
166}