features_cli/
readme_parser.rs

1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::fs;
4use std::path::Path;
5
6/// Information extracted from a README file
7pub struct ReadmeInfo {
8    /// Optional title extracted from the first markdown heading
9    pub title: Option<String>,
10    /// Owner of the feature
11    pub owner: String,
12    /// Description content (everything after the first heading)
13    pub description: String,
14    /// Additional metadata from YAML frontmatter
15    pub meta: HashMap<String, serde_json::Value>,
16}
17
18fn extract_first_title(content: &str) -> Option<String> {
19    for line in content.lines() {
20        let trimmed = line.trim();
21
22        // Check if this line is a title (starting with #)
23        if trimmed.starts_with('#') {
24            // Remove all leading # characters and whitespace
25            let title = trimmed.trim_start_matches('#').trim();
26            if !title.is_empty() {
27                return Some(title.to_string());
28            }
29        }
30    }
31    None
32}
33
34fn read_readme_content(content: &str) -> String {
35    let mut found_first_title = false;
36    let mut lines_after_title: Vec<&str> = Vec::new();
37
38    for line in content.lines() {
39        let trimmed = line.trim();
40
41        // Check if we found the first title (starting with #)
42        if !found_first_title && trimmed.starts_with('#') {
43            found_first_title = true;
44            continue;
45        }
46
47        // Collect all lines after the first title
48        if found_first_title {
49            lines_after_title.push(line);
50        }
51    }
52
53    // Join all lines after the first title
54    lines_after_title.join("\n").trim().to_string()
55}
56
57/// Reads README information from README.md or README.mdx files
58/// Returns ReadmeInfo containing title, owner, description, and metadata
59/// The title is extracted from the first markdown heading (# Title)
60pub fn read_readme_info(readme_path: &Path) -> Result<ReadmeInfo> {
61    if !readme_path.exists() {
62        return Ok(ReadmeInfo {
63            title: None,
64            owner: "".to_string(),
65            description: "".to_string(),
66            meta: HashMap::new(),
67        });
68    }
69
70    let content = fs::read_to_string(readme_path)
71        .with_context(|| format!("could not read README file at `{}`", readme_path.display()))?;
72
73    let mut title: Option<String> = None;
74    let mut owner = "".to_string();
75    let mut description = "".to_string();
76    let mut meta: HashMap<String, serde_json::Value> = HashMap::new();
77
78    // Check if content starts with YAML front matter (---)
79    if let Some(stripped) = content.strip_prefix("---\n") {
80        if let Some(end_pos) = stripped.find("\n---\n") {
81            let yaml_content = &stripped[..end_pos];
82            let markdown_content = stripped[end_pos + 5..].to_string();
83
84            // Parse YAML front matter
85            if let Ok(yaml_value) = serde_yaml::from_str::<serde_yaml::Value>(yaml_content)
86                && let Some(mapping) = yaml_value.as_mapping()
87            {
88                for (key, value) in mapping {
89                    if let Some(key_str) = key.as_str() {
90                        if key_str == "owner" {
91                            if let Some(owner_value) = value.as_str() {
92                                owner = owner_value.to_string();
93                            }
94                        } else {
95                            // Convert YAML value to JSON value for meta
96                            if let Ok(json_value) = serde_json::to_value(value) {
97                                meta.insert(key_str.to_string(), json_value);
98                            }
99                        }
100                    }
101                }
102            }
103
104            // Extract title from markdown content (after frontmatter)
105            title = extract_first_title(&markdown_content);
106            description = read_readme_content(&markdown_content)
107        }
108    } else {
109        // No frontmatter, extract title and description from full content
110        title = extract_first_title(&content);
111        description = read_readme_content(&content)
112    }
113
114    Ok(ReadmeInfo {
115        title,
116        owner,
117        description,
118        meta,
119    })
120}