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    // Try exact filename first
120    let md_path = dir.join(format!("{}.md", name));
121    if md_path.exists() {
122        let content = fs::read_to_string(&md_path).ok()?;
123        let (fm, _body) = parse_context_frontmatter(&content);
124        let doc = ContextDoc {
125            name: fm.name.unwrap_or_else(|| name.to_string()),
126            description: fm.description.unwrap_or_default(),
127            tags: fm
128                .tags
129                .map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
130                .unwrap_or_default(),
131            file: format!("{}.md", name),
132        };
133        return Some((doc, content));
134    }
135
136    // Search by name field
137    for doc in discover_context_docs() {
138        if doc.name.to_lowercase() == name.to_lowercase() {
139            let full_path = dir.join(&doc.file);
140            let content = fs::read_to_string(&full_path).ok()?;
141            return Some((doc, content));
142        }
143    }
144
145    None
146}
147
148/// List context docs (name and description only).
149pub fn list_context_docs() -> Vec<ContextDoc> {
150    discover_context_docs()
151}