fledge 1.1.1

Dev lifecycle CLI. One tool for the dev loop, any language.
use anyhow::{bail, Context, Result};
use console::style;

use super::{
    escape_toml_value, format_lane_toml, load_lane_config, FledgeFileWithLanes,
    LANES_IMPORT_SCHEMA, LANES_SEARCH_SCHEMA,
};
use crate::trust::{determine_trust_tier, determine_trust_tier_from_owner};

pub(crate) fn search_lanes(keyword: Option<&str>, author: Option<&str>, json: bool) -> Result<()> {
    let config = crate::config::Config::load()?;
    let token = config.github_token();

    let query = crate::search::build_search_query_ex(keyword, author, "fledge-lane");

    let sp = crate::spinner::Spinner::start("Searching GitHub for community lanes:");

    let body = crate::github::github_api_get(
        "/search/repositories",
        token.as_deref(),
        &[("q", &query), ("sort", "stars"), ("per_page", "30")],
    )
    .context("searching GitHub for lane repos")?;

    sp.finish();

    let results = crate::search::parse_search_response(&body)?;

    if results.is_empty() {
        if json {
            let result = serde_json::json!({
                "schema_version": LANES_SEARCH_SCHEMA,
                "results": [],
            });
            println!("{}", serde_json::to_string_pretty(&result)?);
        } else {
            println!(
                "{} No community lanes found{}.",
                style("*").cyan().bold(),
                keyword
                    .map(|q| format!(" matching '{q}'"))
                    .unwrap_or_default()
            );
        }
        return Ok(());
    }

    if json {
        let entries: Vec<serde_json::Value> = results
            .iter()
            .map(|r| {
                let tier = determine_trust_tier_from_owner(&r.owner);
                serde_json::json!({
                    "owner": r.owner,
                    "name": r.name,
                    "description": r.description,
                    "stars": r.stars,
                    "url": r.url,
                    "topics": r.topics,
                    "trust_tier": tier.label(),
                })
            })
            .collect();
        let result = serde_json::json!({
            "schema_version": LANES_SEARCH_SCHEMA,
            "results": entries,
        });
        println!("{}", serde_json::to_string_pretty(&result)?);
        return Ok(());
    }

    println!("{}\n", style("Community lanes on GitHub:").bold());
    let max_name = results
        .iter()
        .map(|r| r.full_name().len())
        .max()
        .unwrap_or(0);
    for r in &results {
        let tier = determine_trust_tier_from_owner(&r.owner);
        let stars = crate::search::format_stars(r.stars);
        let desc = if r.description.chars().count() > 60 {
            let truncated: String = r.description.chars().take(57).collect();
            format!("{truncated}...")
        } else {
            r.description.clone()
        };
        let topic_str = if r.topics.is_empty() {
            String::new()
        } else {
            format!(" [{}]", r.topics.join(", "))
        };
        println!(
            "  {:<width$}  [{}]  {}  {}{}",
            style(&r.full_name()).green(),
            tier.styled_label(),
            style(format!("(⭐ {})", stars)).dim(),
            style(&desc).dim(),
            style(&topic_str).cyan(),
            width = max_name,
        );
    }
    println!(
        "\n{}",
        style("Import with: fledge lane import <owner/repo[/path]>").dim()
    );

    Ok(())
}

pub(crate) fn import_lanes(source: &str, yes: bool, json: bool) -> Result<()> {
    let cwd = std::env::current_dir()?;
    let local_path = cwd.join("fledge.toml");

    if !local_path.exists() {
        bail!(
            "No fledge.toml found. Run {} first.",
            style("fledge run --init").cyan()
        );
    }

    let config = crate::config::Config::load()?;
    let token = config.github_token();

    let (owner, repo, subpath, git_ref) = parse_import_source(source);

    let display_source = format!(
        "{}/{}{}{}",
        owner,
        repo,
        subpath
            .as_ref()
            .map(|p| format!("/{p}"))
            .unwrap_or_default(),
        git_ref
            .as_ref()
            .map(|r| format!("@{r}"))
            .unwrap_or_default()
    );

    let tier = determine_trust_tier(&display_source);
    if !json {
        println!(
            "\n{} Importing lanes from: {} [{}]",
            style("!").yellow().bold(),
            style(&display_source).cyan(),
            tier.styled_label()
        );
        if tier != crate::trust::TrustTier::Official {
            println!(
                "  {} Lanes can execute arbitrary commands on your system.",
                style("*").yellow()
            );
            println!(
                "  {} Only import lanes from sources you trust.\n",
                style("*").yellow()
            );
        }
    }

    if !yes && tier != crate::trust::TrustTier::Official {
        if !crate::utils::is_interactive() {
            bail!(
                "Importing community lanes requires confirmation in non-interactive mode.\n  \
                 Use --yes to acknowledge that lanes can execute arbitrary commands."
            );
        }
        let confirm = dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
            .with_prompt(format!("Import lanes from '{display_source}'?"))
            .default(false)
            .interact()?;
        if !confirm {
            bail!("Lane import cancelled.");
        }
    }

    let sp = if json {
        None
    } else {
        Some(crate::spinner::Spinner::start(&format!(
            "Fetching lanes from {}:",
            display_source,
        )))
    };

    let ref_param = git_ref.as_deref().unwrap_or("HEAD");
    let remote_path = match &subpath {
        Some(p) => format!("{p}/fledge.toml"),
        None => "fledge.toml".to_string(),
    };
    let body = crate::github::github_api_get(
        &format!("/repos/{owner}/{repo}/contents/{remote_path}"),
        token.as_deref(),
        &[("ref", ref_param)],
    )
    .context(format!("fetching {remote_path} from remote repo"))?;

    if let Some(s) = sp {
        s.finish();
    }

    let content_b64 = body
        .get("content")
        .and_then(|c| c.as_str())
        .ok_or_else(|| anyhow::anyhow!("Remote repo has no fledge.toml or it's not a file"))?;

    let cleaned: String = content_b64.chars().filter(|c| !c.is_whitespace()).collect();
    let decoded = base64_decode(&cleaned).context("decoding fledge.toml content")?;
    let remote_content = String::from_utf8(decoded).context("fledge.toml is not valid UTF-8")?;

    let remote_config: FledgeFileWithLanes =
        toml::from_str(&remote_content).context("parsing remote fledge.toml")?;

    if remote_config.lanes.is_empty() {
        bail!("Remote repo has no [lanes] defined in fledge.toml.");
    }

    let existing = load_lane_config()?;

    let mut imported_lanes = Vec::new();
    let mut skipped = Vec::new();
    let mut import_content = String::new();

    import_content.push_str(&format!("# Imported from {display_source}\n\n"));

    for (task_name, task_def) in &remote_config.tasks {
        if existing.tasks.contains_key(task_name) {
            continue;
        }
        let cmd = escape_toml_value(task_def.cmd());
        import_content.push_str(&format!("[tasks.{task_name}]\ncmd = \"{cmd}\"\n\n"));
    }

    for (lane_name, lane) in &remote_config.lanes {
        if existing.lanes.contains_key(lane_name) {
            skipped.push(lane_name.clone());
            continue;
        }
        import_content.push_str(&format_lane_toml(lane_name, lane));
        imported_lanes.push(lane_name.clone());
    }

    let safe_name = format!(
        "{}-{}{}",
        owner.to_lowercase(),
        repo.to_lowercase(),
        subpath
            .as_ref()
            .map(|p| format!("-{}", p.replace('/', "-").to_lowercase()))
            .unwrap_or_default()
    );
    let relative_file = format!(".fledge/lanes/{safe_name}.toml");

    if imported_lanes.is_empty() {
        if json {
            let result = serde_json::json!({
                "schema_version": LANES_IMPORT_SCHEMA,
                "action": "import",
                "source": display_source,
                "trust_tier": tier.label(),
                "imported": [],
                "skipped": skipped,
                "file": relative_file,
                "written": false,
            });
            println!("{}", serde_json::to_string_pretty(&result)?);
        } else {
            println!(
                "{} All lanes from {} already exist locally ({})",
                style("*").cyan().bold(),
                display_source,
                skipped.join(", ")
            );
        }
        return Ok(());
    }

    let lanes_dir = cwd.join(".fledge").join("lanes");
    std::fs::create_dir_all(&lanes_dir).context("creating .fledge/lanes directory")?;

    let import_path = lanes_dir.join(format!("{safe_name}.toml"));
    std::fs::write(&import_path, import_content.trim_start()).context("writing imported lanes")?;

    if json {
        let result = serde_json::json!({
            "schema_version": LANES_IMPORT_SCHEMA,
            "action": "import",
            "source": display_source,
            "trust_tier": tier.label(),
            "imported": imported_lanes,
            "skipped": skipped,
            "file": relative_file,
            "written": true,
        });
        println!("{}", serde_json::to_string_pretty(&result)?);
    } else {
        println!(
            "{} Imported {} lane(s) from {}",
            style("").green().bold(),
            imported_lanes.len(),
            display_source
        );
        for name in &imported_lanes {
            println!("  {} {}", style("+").green(), style(name).cyan());
        }
        println!(
            "  {} Saved to {}",
            style("").dim(),
            style(&relative_file).cyan()
        );
        if !skipped.is_empty() {
            println!(
                "  {} Skipped (already exist): {}",
                style("*").dim(),
                skipped.join(", ")
            );
        }
    }

    Ok(())
}

pub(crate) fn parse_import_source(
    source: &str,
) -> (String, String, Option<String>, Option<String>) {
    let source = source
        .strip_prefix("https://github.com/")
        .unwrap_or(source)
        .trim_end_matches(".git");

    let (path, git_ref) = if let Some((p, r)) = source.split_once('@') {
        (p, Some(r.to_string()))
    } else {
        (source, None)
    };

    let parts: Vec<&str> = path.splitn(3, '/').collect();
    let owner = parts.first().unwrap_or(&"").to_string();
    let repo = parts.get(1).unwrap_or(&"").to_string();
    let subpath = parts
        .get(2)
        .map(|s| s.to_string())
        .filter(|s| !s.is_empty());

    (owner, repo, subpath, git_ref)
}

pub(crate) fn base64_decode(input: &str) -> Result<Vec<u8>> {
    use base64::Engine;
    base64::engine::general_purpose::STANDARD
        .decode(input)
        .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(input))
        .context("invalid base64 input")
}