Files
DevFlow/crates/df-project/src/scan

//! README 读取与首段抽取 — 纯 IO + 文本规则。
//!
//! `read_readme_raw` 被本模块(`extract_description`)与 [`super::sample`](`crate::scan::sample`)
//! (`collect_sample`)共用,故 `pub(super)` 暴露。

use std::path::Path;

use super::truncate_chars;

/// extract_description 最大字符数
const EXTRACT_DESC_MAX: usize = 200;

/// 读 README 原始内容(不做处理)。
///
/// 支持多种 README 变体(`README.md` / `README.zh.md` / `README_zh.md` / `README_EN.md` 等),
/// 返回首个存在的文件内容。
pub(super) fn read_readme_raw(root: &Path) -> Option<String> {
    for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
        let p = root.join(name);
        if p.is_file() {
            if let Ok(content) = std::fs::read_to_string(&p) {
                return Some(content);
            }
        }
    }
    None
}

/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
///
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
pub fn extract_description(root: &Path) -> Option<String> {
    // 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
    let content = read_readme_raw(root)?;
    let mut text = String::new();
    let mut started = false;
    for raw_line in content.lines() {
        let line = raw_line.trim();
        if line.is_empty() {
            if started {
                break; // 段落结束
            }
            continue; // 首段尚未开始,跳过开头空行
        }
        // 段落开始后不再跳行,直接累加
        if !started {
            // 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
            if line.starts_with('#')
                || line.starts_with("<!--")
                || line.starts_with('!')
                || line.starts_with('<')
            {
                continue;
            }
            started = true;
        }
        if !text.is_empty() {
            text.push(' ');
        }
        text.push_str(line);
    }
    let desc = text.trim().to_string();
    if desc.is_empty() {
        return None;
    }
    Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;

    /// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
    fn scratch(name: &str) -> PathBuf {
        let mut p = std::env::temp_dir();
        p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
        let _ = fs::remove_dir_all(&p);
        fs::create_dir_all(&p).unwrap();
        p
    }

    #[test]
    fn extract_desc_skips_title_badge() {
        let d = scratch("desc");
        // 标题 + badge 图片行应跳过,首段为正文
        fs::write(
            d.join("README.md"),
            "# My Project\n\n![badge](https://x.io/badge.svg)\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
        )
        .unwrap();
        let desc = extract_description(&d).unwrap();
        assert!(desc.contains("示例项目"));
        assert!(desc.contains("第二行正文"));
        assert!(!desc.contains("My Project"));
        assert!(!desc.contains("badge"));
        assert!(!desc.contains("安装"));
        fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn extract_desc_no_readme_returns_none() {
        let d = scratch("nodesc");
        assert!(extract_description(&d).is_none());
        fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn extract_desc_truncates_long() {
        let d = scratch("longdesc");
        let long = "啊".repeat(500);
        fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
        let desc = extract_description(&d).unwrap();
        // 截断到 200 字 + 末尾「…(已截断)」标记
        assert!(desc.chars().count() <= 210);
        assert!(desc.contains("已截断"));
        fs::remove_dir_all(&d).ok();
    }
}