fledge 0.15.0

Dev-lifecycle CLI — scaffolding, tasks, lanes, plugins, and more.
use anyhow::Result;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
    pub owner: String,
    pub name: String,
    pub description: String,
    pub stars: u64,
    pub url: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub topics: Vec<String>,
}

impl SearchResult {
    pub fn full_name(&self) -> String {
        format!("{}/{}", self.owner, self.name)
    }
}

pub fn build_search_query_ex(keyword: Option<&str>, author: Option<&str>, topic: &str) -> String {
    let mut parts = Vec::new();
    if let Some(kw) = keyword {
        parts.push(format!("{kw} in:name,description,topics"));
    }
    parts.push(format!("topic:{topic}"));
    if let Some(a) = author {
        parts.push(format!("user:{a}"));
    }
    parts.join(" ")
}

pub fn parse_search_response(body: &serde_json::Value) -> Result<Vec<SearchResult>> {
    let items = body
        .get("items")
        .and_then(|v| v.as_array())
        .ok_or_else(|| anyhow::anyhow!("unexpected GitHub API response: missing 'items' array"))?;

    let results: Vec<SearchResult> = items
        .iter()
        .filter_map(|item| {
            let owner = item
                .get("owner")
                .and_then(|o| o.get("login"))
                .and_then(|l| l.as_str())?;
            let name = item.get("name").and_then(|n| n.as_str())?;
            let description = item
                .get("description")
                .and_then(|d| d.as_str())
                .unwrap_or("No description");
            let stars = item
                .get("stargazers_count")
                .and_then(|s| s.as_u64())
                .unwrap_or(0);
            let url = item.get("html_url").and_then(|u| u.as_str()).unwrap_or("");
            let topics = item
                .get("topics")
                .and_then(|t| t.as_array())
                .map(|arr| {
                    arr.iter()
                        .filter_map(|v| v.as_str().map(String::from))
                        .collect()
                })
                .unwrap_or_default();

            Some(SearchResult {
                owner: owner.to_string(),
                name: name.to_string(),
                description: description.to_string(),
                stars,
                url: url.to_string(),
                topics,
            })
        })
        .collect();

    Ok(results)
}

pub fn format_stars(count: u64) -> String {
    if count >= 1000 {
        let k = count as f64 / 1000.0;
        if k >= 10.0 {
            format!("{:.0}k", k)
        } else {
            format!("{:.1}k", k)
        }
    } else {
        format!("{}", count)
    }
}

pub fn urlencod(s: &str) -> String {
    s.bytes()
        .map(|b| match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                String::from(b as char)
            }
            b' ' => "%20".to_string(),
            _ => format!("%{:02X}", b),
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_search_response_valid() {
        let json = serde_json::json!({
            "total_count": 2,
            "items": [
                {
                    "name": "fledge-rust-template",
                    "owner": { "login": "CorvidLabs" },
                    "description": "A Rust CLI template for fledge",
                    "stargazers_count": 42,
                    "html_url": "https://github.com/CorvidLabs/fledge-rust-template"
                },
                {
                    "name": "fledge-python",
                    "owner": { "login": "someuser" },
                    "description": "Python project template",
                    "stargazers_count": 10,
                    "html_url": "https://github.com/someuser/fledge-python"
                }
            ]
        });

        let results = parse_search_response(&json).unwrap();
        assert_eq!(results.len(), 2);
        assert_eq!(results[0].owner, "CorvidLabs");
        assert_eq!(results[0].name, "fledge-rust-template");
        assert_eq!(results[0].stars, 42);
        assert_eq!(results[1].full_name(), "someuser/fledge-python");
    }

    #[test]
    fn parse_search_response_empty() {
        let json = serde_json::json!({
            "total_count": 0,
            "items": []
        });

        let results = parse_search_response(&json).unwrap();
        assert!(results.is_empty());
    }

    #[test]
    fn parse_search_response_missing_description() {
        let json = serde_json::json!({
            "total_count": 1,
            "items": [
                {
                    "name": "bare-template",
                    "owner": { "login": "user" },
                    "description": null,
                    "stargazers_count": 5,
                    "html_url": "https://github.com/user/bare-template"
                }
            ]
        });

        let results = parse_search_response(&json).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].description, "No description");
    }

    #[test]
    fn parse_search_response_missing_items() {
        let json = serde_json::json!({ "total_count": 0 });
        let result = parse_search_response(&json);
        assert!(result.is_err());
    }

    #[test]
    fn build_search_query_no_keyword() {
        let q = build_search_query_ex(None, None, "fledge-template");
        assert_eq!(q, "topic:fledge-template");
    }

    #[test]
    fn build_search_query_with_keyword() {
        let q = build_search_query_ex(Some("rust"), None, "fledge-template");
        assert_eq!(q, "rust in:name,description,topics topic:fledge-template");
    }

    #[test]
    fn build_search_query_ex_with_author() {
        let q = build_search_query_ex(Some("rust"), Some("corvidlabs"), "fledge-template");
        assert_eq!(
            q,
            "rust in:name,description,topics topic:fledge-template user:corvidlabs"
        );
    }

    #[test]
    fn build_search_query_ex_author_only() {
        let q = build_search_query_ex(None, Some("corvidlabs"), "fledge-plugin");
        assert_eq!(q, "topic:fledge-plugin user:corvidlabs");
    }

    #[test]
    fn format_stars_below_thousand() {
        assert_eq!(format_stars(0), "0");
        assert_eq!(format_stars(42), "42");
        assert_eq!(format_stars(999), "999");
    }

    #[test]
    fn format_stars_thousands() {
        assert_eq!(format_stars(1000), "1.0k");
        assert_eq!(format_stars(1500), "1.5k");
        assert_eq!(format_stars(2300), "2.3k");
    }

    #[test]
    fn format_stars_ten_thousands() {
        assert_eq!(format_stars(10000), "10k");
        assert_eq!(format_stars(15000), "15k");
        assert_eq!(format_stars(123456), "123k");
    }

    #[test]
    fn search_result_full_name() {
        let r = SearchResult {
            owner: "CorvidLabs".to_string(),
            name: "fledge-templates".to_string(),
            description: "Templates".to_string(),
            stars: 10,
            url: "https://github.com/CorvidLabs/fledge-templates".to_string(),
            topics: vec![],
        };
        assert_eq!(r.full_name(), "CorvidLabs/fledge-templates");
    }

    #[test]
    fn json_output_format() {
        let results = vec![SearchResult {
            owner: "test".to_string(),
            name: "tpl".to_string(),
            description: "A template".to_string(),
            stars: 5,
            url: "https://github.com/test/tpl".to_string(),
            topics: vec!["fledge-template".to_string()],
        }];
        let json: serde_json::Value =
            serde_json::from_str(&serde_json::to_string_pretty(&results).unwrap()).unwrap();
        let arr = json.as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["owner"], "test");
        assert_eq!(arr[0]["name"], "tpl");
        assert_eq!(arr[0]["stars"], 5);
    }

    #[test]
    fn urlencod_basic() {
        assert_eq!(urlencod("hello world"), "hello%20world");
        assert_eq!(urlencod("topic:fledge-template"), "topic%3Afledge-template");
        assert_eq!(urlencod("rust"), "rust");
    }

    #[test]
    fn parse_search_response_skips_items_without_owner() {
        let json = serde_json::json!({
            "total_count": 1,
            "items": [
                {
                    "name": "orphan",
                    "description": "No owner field",
                    "stargazers_count": 1,
                    "html_url": "https://github.com/x/orphan"
                }
            ]
        });

        let results = parse_search_response(&json).unwrap();
        assert!(results.is_empty());
    }

    #[test]
    fn parse_search_response_extracts_topics() {
        let json = serde_json::json!({
            "total_count": 1,
            "items": [
                {
                    "name": "fledge-rust-template",
                    "owner": { "login": "CorvidLabs" },
                    "description": "A Rust template",
                    "stargazers_count": 5,
                    "html_url": "https://github.com/CorvidLabs/fledge-rust-template",
                    "topics": ["fledge-template", "rust", "cli"]
                }
            ]
        });

        let results = parse_search_response(&json).unwrap();
        assert_eq!(results[0].topics, vec!["fledge-template", "rust", "cli"]);
    }
}