Skip to main content

agentctl/hub/
generate.rs

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
10/// Generate skills index. CLI `hub_id_override` takes precedence over `agentctl.toml`.
11pub 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                    // Get relative path from root for pattern matching
216                    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}