tangled-cli 0.1.0

CLI for interacting with Tangled, an AT Protocol-based git collaboration platform
use anyhow::{anyhow, Result};
use futures_util::StreamExt;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;

use crate::cli::{
    Cli, OutputFormat, SpindleCommand, SpindleConfigArgs, SpindleListArgs,
    SpindleLogsArgs, SpindleRunArgs, SpindleSecretAddArgs,
    SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs,
};

pub async fn run(cli: &Cli, cmd: SpindleCommand) -> Result<()> {
    match cmd {
        SpindleCommand::List(args) => list(cli, args).await,
        SpindleCommand::Config(args) => config(args).await,
        SpindleCommand::Run(args) => run_pipeline(args).await,
        SpindleCommand::Logs(args) => logs(args).await,
        SpindleCommand::Secret(cmd) => secret(cli, cmd).await,
    }
}

async fn list(cli: &Cli, args: SpindleListArgs) -> Result<()> {
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;

    let pds = session
        .pds
        .clone()
        .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
        .unwrap_or_else(|| "https://bsky.social".into());
    let (owner, name) = parse_repo_ref(
        args.repo.as_deref().unwrap_or(&session.handle),
        &session.handle,
    );
    let info =
        crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;

    let pipelines =
        crate::ops::repo::list_pipelines(&pds, &info.did, &auth).await?;

    if matches!(cli.format, OutputFormat::Json | OutputFormat::Yaml) {
        return crate::util::print_serialized(cli.format, &pipelines);
    }

    if pipelines.is_empty() {
        println!("No pipelines found for {}/{}", owner, name);
    } else {
        crate::util::print_table(
            ["RKEY", "KIND", "REPOSITORY", "WORKFLOWS"],
            pipelines.into_iter().map(|pipeline| {
                let workflows = pipeline
                    .pipeline
                    .workflows
                    .iter()
                    .map(|workflow| workflow.name.as_str())
                    .collect::<Vec<_>>()
                    .join(", ");
                [
                    pipeline.rkey,
                    pipeline.pipeline.trigger_metadata.kind,
                    pipeline.pipeline.trigger_metadata.repo.repo,
                    workflows,
                ]
            }),
        );
    }
    Ok(())
}

async fn config(args: SpindleConfigArgs) -> Result<()> {
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;

    if args.enable && args.disable {
        return Err(anyhow!("Cannot use --enable and --disable together"));
    }

    if !args.enable && !args.disable && args.url.is_none() {
        return Err(anyhow!("Must provide --enable, --disable, or --url"));
    }

    let pds = session
        .pds
        .clone()
        .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
        .unwrap_or_else(|| "https://bsky.social".into());
    let (owner, name) = parse_repo_ref(
        args.repo.as_deref().unwrap_or(&session.handle),
        &session.handle,
    );
    let info =
        crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;

    let new_spindle = if args.disable {
        None
    } else if let Some(url) = args.url.as_deref() {
        Some(url)
    } else if args.enable {
        // Default spindle URL
        Some("https://spindle.tangled.sh")
    } else {
        return Err(anyhow!("Invalid flags combination"));
    };

    crate::ops::repo::update_repo_spindle(
        &pds,
        &info.did,
        &info.rkey,
        new_spindle,
        &auth,
    )
    .await?;

    if args.disable {
        println!("Disabled spindle for {}/{}", owner, name);
    } else {
        println!(
            "Enabled spindle for {}/{} ({})",
            owner,
            name,
            new_spindle.unwrap_or_default()
        );
    }
    Ok(())
}

async fn run_pipeline(args: SpindleRunArgs) -> Result<()> {
    println!(
        "Spindle run (stub) repo={:?} branch={:?} wait={}",
        args.repo, args.branch, args.wait
    );
    Ok(())
}

async fn logs(args: SpindleLogsArgs) -> Result<()> {
    // Parse job_id: format is "knot:rkey:name" or just "name" (use repo context)
    let parts: Vec<&str> = args.job_id.split(':').collect();
    let (knot, rkey, name) = if parts.len() == 3 {
        (
            parts[0].to_string(),
            parts[1].to_string(),
            parts[2].to_string(),
        )
    } else if parts.len() == 1 {
        // Use repo context - need to get repo info
        let session = crate::util::load_session_with_refresh().await?;
        let pds = session
            .pds
            .clone()
            .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
            .unwrap_or_else(|| "https://bsky.social".into());
        // Get repo info from current directory context or default to user's handle
        let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
        let info = crate::ops::repo::get_repo_info(
            &pds,
            &session.handle,
            &session.handle,
            &auth,
        )
        .await?;
        (info.knot, info.rkey, parts[0].to_string())
    } else {
        return Err(anyhow!(
            "Invalid job_id format. Expected 'knot:rkey:name' or 'name'"
        ));
    };

    // Build WebSocket URL - spindle base is typically https://spindle.tangled.sh
    let spindle_base = std::env::var("TANGLED_SPINDLE_BASE")
        .unwrap_or_else(|_| "wss://spindle.tangled.sh".to_string());
    let ws_url =
        format!("{}/spindle/logs/{}/{}/{}", spindle_base, knot, rkey, name);

    // Connect to WebSocket
    let (ws_stream, _) = crate::progress::with_spinner(
        format!("Connecting to logs stream for {knot}:{rkey}:{name}..."),
        connect_async(&ws_url),
    )
    .await
    .map_err(|e| anyhow!("Failed to connect to log stream: {}", e))?;

    let (mut _write, mut read) = ws_stream.split();

    // Stream log messages
    let mut line_count = 0;
    let max_lines = args.lines.unwrap_or(usize::MAX);

    while let Some(msg) = read.next().await {
        match msg {
            Ok(Message::Text(text)) => {
                println!("{}", text);
                line_count += 1;
                if line_count >= max_lines {
                    break;
                }
            }
            Ok(Message::Close(_)) => {
                break;
            }
            Err(e) => {
                return Err(anyhow!("WebSocket error: {}", e));
            }
            _ => {}
        }
    }

    Ok(())
}

async fn secret(cli: &Cli, cmd: SpindleSecretCommand) -> Result<()> {
    match cmd {
        SpindleSecretCommand::List(args) => secret_list(cli, args).await,
        SpindleSecretCommand::Add(args) => secret_add(args).await,
        SpindleSecretCommand::Remove(args) => secret_remove(args).await,
    }
}

async fn secret_list(cli: &Cli, args: SpindleSecretListArgs) -> Result<()> {
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
    let pds = session
        .pds
        .clone()
        .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
        .unwrap_or_else(|| "https://bsky.social".into());
    let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
    let info =
        crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
    let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);

    // Get spindle base from repo config or use default
    let spindle_base = info
        .spindle
        .clone()
        .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok())
        .unwrap_or_else(|| "https://spindle.tangled.sh".to_string());
    let secrets = crate::ops::secrets::list_repo_secrets(
        &spindle_base,
        &pds,
        &auth,
        &repo_at,
    )
    .await?;
    if matches!(cli.format, OutputFormat::Json | OutputFormat::Yaml) {
        return crate::util::print_serialized(cli.format, &secrets);
    }

    if secrets.is_empty() {
        println!("No secrets configured for {}", args.repo);
    } else {
        crate::util::print_table(
            ["KEY", "CREATED AT", "CREATED BY"],
            secrets.into_iter().map(|secret| {
                [secret.key, secret.created_at, secret.created_by]
            }),
        );
    }
    Ok(())
}

async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> {
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
    let pds = session
        .pds
        .clone()
        .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
        .unwrap_or_else(|| "https://bsky.social".into());
    let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
    let info =
        crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
    let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);

    // Get spindle base from repo config or use default
    let spindle_base = info
        .spindle
        .clone()
        .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok())
        .unwrap_or_else(|| "https://spindle.tangled.sh".to_string());
    // Handle special value patterns: @file or - (stdin)
    let value = if args.value == "-" {
        // Read from stdin
        use std::io::Read;
        let mut buffer = String::new();
        std::io::stdin().read_to_string(&mut buffer)?;
        buffer
    } else if let Some(path) = args.value.strip_prefix('@') {
        // Read from file, expand ~ if needed
        let expanded_path = if path.starts_with("~/") {
            if let Ok(home) = std::env::var("HOME") {
                path.replacen("~/", &format!("{}/", home), 1)
            } else {
                path.to_string()
            }
        } else {
            path.to_string()
        };
        std::fs::read_to_string(&expanded_path).map_err(|e| {
            anyhow!("Failed to read file '{}': {}", expanded_path, e)
        })?
    } else {
        // Use value as-is
        args.value
    };

    crate::ops::secrets::add_repo_secret(
        &spindle_base,
        &pds,
        &auth,
        &repo_at,
        &args.key,
        &value,
    )
    .await?;
    println!("Added secret '{}' to {}", args.key, args.repo);
    Ok(())
}

async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> {
    let session = crate::util::load_session_with_refresh().await?;
    let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
    let pds = session
        .pds
        .clone()
        .or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
        .unwrap_or_else(|| "https://bsky.social".into());
    let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
    let info =
        crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
    let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);

    // Get spindle base from repo config or use default
    let spindle_base = info
        .spindle
        .clone()
        .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok())
        .unwrap_or_else(|| "https://spindle.tangled.sh".to_string());
    crate::ops::secrets::remove_repo_secret(
        &spindle_base,
        &pds,
        &auth,
        &repo_at,
        &args.key,
    )
    .await?;
    println!("Removed secret '{}' from {}", args.key, args.repo);
    Ok(())
}

fn parse_repo_ref<'a>(
    spec: &'a str,
    default_owner: &'a str,
) -> (&'a str, &'a str) {
    if let Some((owner, name)) = spec.split_once('/') {
        (owner, name)
    } else {
        (default_owner, spec)
    }
}