chub_core/team/
context.rs1use std::fs;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::team::project::project_chub_dir;
7
8#[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 pub file: String,
17}
18
19#[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
34fn 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
50pub 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
115pub fn get_context_doc(name: &str) -> Option<(ContextDoc, String)> {
117 let dir = context_dir()?;
118
119 if name.contains("..") || name.contains('/') || name.contains('\\') || name.contains('\0') {
121 return None;
122 }
123
124 let md_path = dir.join(format!("{}.md", name));
126
127 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 for doc in discover_context_docs() {
149 if doc.name.to_lowercase() == name.to_lowercase() {
150 let full_path = dir.join(&doc.file);
151 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
163pub fn list_context_docs() -> Vec<ContextDoc> {
165 discover_context_docs()
166}