1use std::path::Path;
2
3use anyhow::Result;
4use chrono::Utc;
5use git2::Repository;
6
7use super::config::HubConfig;
8use super::schema::{DocEntry, DocStatus, DocsIndex, DocsMetadata, SkillEntry, SkillsIndex};
9
10pub fn generate_skills_index(path: &Path, hub_id_override: &str) -> Result<SkillsIndex> {
12 let cfg = HubConfig::load(path);
13 let hub_id = if hub_id_override != "default" {
14 hub_id_override.to_string()
15 } else {
16 cfg.hub
17 .id
18 .clone()
19 .unwrap_or_else(|| hub_id_override.to_string())
20 };
21
22 let repo = Repository::discover(path)?;
23 let git_url = remote_url(&repo);
24 let mut skills = Vec::new();
25
26 for entry in std::fs::read_dir(path)?.filter_map(|e| e.ok()) {
27 let skill_dir = entry.path();
28 if !skill_dir.is_dir() {
29 continue;
30 }
31 if skill_dir
32 .file_name()
33 .and_then(|n| n.to_str())
34 .map(|n| n.starts_with('.'))
35 .unwrap_or(false)
36 {
37 continue;
38 }
39 let skill_md = skill_dir.join("SKILL.md");
40 if !skill_md.exists() {
41 continue;
42 }
43
44 let content = std::fs::read_to_string(&skill_md)?;
45 let fm = parse_frontmatter(&content)?;
46
47 let slug = skill_dir
48 .file_name()
49 .and_then(|n| n.to_str())
50 .unwrap_or("")
51 .to_string();
52 let rel_path = skill_dir.strip_prefix(path)?.display().to_string();
53 let commit = last_commit_hash(&repo, &skill_md);
54 let has_lifecycle = skill_dir.join("lifecycle.yaml").exists();
55
56 skills.push(SkillEntry {
57 slug,
58 name: fm_str(&fm, "name"),
59 description: fm_str(&fm, "description"),
60 version: fm
61 .get("metadata")
62 .and_then(|m| m.get("version"))
63 .and_then(|v| v.as_str())
64 .unwrap_or("0.1.0")
65 .to_string(),
66 compatibility: fm
67 .get("compatibility")
68 .and_then(|v| v.as_str())
69 .map(str::to_string),
70 license: fm
71 .get("license")
72 .and_then(|v| v.as_str())
73 .map(str::to_string),
74 git_url: git_url.clone(),
75 path: rel_path,
76 commit,
77 has_lifecycle,
78 });
79 }
80
81 Ok(SkillsIndex {
82 hub_id,
83 generated_at: Utc::now().to_rfc3339(),
84 skills,
85 })
86}
87
88pub fn generate_docs_index(path: &Path) -> Result<DocsIndex> {
89 let cfg = HubConfig::load(path);
90 let repo = Repository::discover(path)?;
91 let repo_commit = head_commit_hash(&repo);
92 let mut entries = Vec::new();
93
94 for file in glob_md_files(path, &cfg) {
95 let content = std::fs::read_to_string(&file)?;
96 if !content.starts_with("---") {
97 continue;
98 }
99 let fm = match parse_frontmatter(&content) {
100 Ok(fm) => fm,
101 Err(_) => continue,
102 };
103
104 let rel_path = file.strip_prefix(path)?.display().to_string();
105 let commit_hash = last_commit_hash(&repo, &file);
106 let read_when: Vec<String> = fm
107 .get("read_when")
108 .and_then(|v| v.as_sequence())
109 .map(|seq| {
110 seq.iter()
111 .filter_map(|v| v.as_str().map(str::to_string))
112 .collect()
113 })
114 .unwrap_or_default();
115
116 let status = match fm.get("status").and_then(|v| v.as_str()) {
117 Some("active") => DocStatus::Active,
118 Some("deprecated") => DocStatus::Deprecated,
119 _ => DocStatus::Draft,
120 };
121
122 entries.push(DocEntry {
123 title: fm_str(&fm, "title"),
124 summary: fm_str(&fm, "summary"),
125 path: rel_path,
126 commit_hash,
127 last_updated: fm_str(&fm, "last_updated"),
128 status,
129 read_when,
130 });
131 }
132
133 let total = entries.len();
134 Ok(DocsIndex {
135 kind: "docs".into(),
136 version: "1.0".into(),
137 entries,
138 metadata: DocsMetadata {
139 generated_at: Utc::now().to_rfc3339(),
140 commit_hash: repo_commit,
141 total_entries: total,
142 },
143 })
144}
145
146fn parse_frontmatter(content: &str) -> Result<serde_yaml::Mapping> {
147 let parts: Vec<&str> = content.splitn(3, "---").collect();
148 anyhow::ensure!(parts.len() >= 3, "invalid frontmatter");
149 Ok(serde_yaml::from_str(parts[1])?)
150}
151
152fn fm_str(fm: &serde_yaml::Mapping, key: &str) -> String {
153 fm.get(key)
154 .and_then(|v| v.as_str())
155 .unwrap_or("")
156 .to_string()
157}
158
159fn last_commit_hash(repo: &Repository, path: &Path) -> String {
160 let mut revwalk = match repo.revwalk() {
161 Ok(r) => r,
162 Err(_) => return String::new(),
163 };
164 let _ = revwalk.push_head();
165 let _ = revwalk.set_sorting(git2::Sort::TIME);
166
167 for oid in revwalk.filter_map(|r| r.ok()) {
168 let commit = match repo.find_commit(oid) {
169 Ok(c) => c,
170 Err(_) => continue,
171 };
172 let tree = match commit.tree() {
173 Ok(t) => t,
174 Err(_) => continue,
175 };
176 let rel = match path.strip_prefix(repo.workdir().unwrap_or(path)) {
177 Ok(r) => r,
178 Err(_) => continue,
179 };
180 if tree.get_path(rel).is_ok() {
181 return oid.to_string();
182 }
183 }
184 String::new()
185}
186
187fn head_commit_hash(repo: &Repository) -> String {
188 repo.head()
189 .ok()
190 .and_then(|r| r.peel_to_commit().ok())
191 .map(|c| c.id().to_string())
192 .unwrap_or_default()
193}
194
195fn remote_url(repo: &Repository) -> String {
196 repo.find_remote("origin")
197 .ok()
198 .and_then(|r| r.url().map(str::to_string))
199 .unwrap_or_default()
200}
201
202fn glob_md_files(path: &Path, cfg: &HubConfig) -> Vec<std::path::PathBuf> {
203 fn collect_files(
204 current_path: &Path,
205 root_path: &Path,
206 cfg: &HubConfig,
207 files: &mut Vec<std::path::PathBuf>,
208 ) {
209 if let Ok(entries) = std::fs::read_dir(current_path) {
210 for entry in entries.filter_map(|e| e.ok()) {
211 let p = entry.path();
212 if p.is_dir() {
213 collect_files(&p, root_path, cfg, files);
214 } else if p.extension().and_then(|e| e.to_str()) == Some("md") {
215 let rel_path = match p.strip_prefix(root_path) {
217 Ok(rel) => rel.display().to_string().replace('\\', "/"),
218 Err(_) => continue,
219 };
220
221 if !cfg.is_ignored(&rel_path) {
222 files.push(p);
223 }
224 }
225 }
226 }
227 }
228
229 let mut files = Vec::new();
230 collect_files(path, path, cfg, &mut files);
231 files
232}