hashtree-cli 0.2.66

Hashtree daemon and CLI - content-addressed storage with P2P sync
Documentation
use anyhow::{Context, Result};
use git_remote_htree::nostr_client::resolve_identity;
use nostr::{
    Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag, Timestamp, ToBech32,
};
use nostr_sdk::Client;
use std::collections::HashMap;
use std::time::Duration;

const KIND_APP_DATA: u16 = 30078;
const LABEL_HASHTREE: &str = "hashtree";
const LABEL_GIT: &str = "git";

pub(crate) async fn list_repos(owner: Option<&str>) -> Result<()> {
    let owner = owner
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("self");

    let (pubkey, _secret_key) = resolve_identity(owner)?;
    let config = hashtree_config::Config::load_or_default();
    let author = PublicKey::from_hex(&pubkey).context("Invalid owner pubkey")?;
    let owner_display = author.to_bech32().unwrap_or(pubkey);

    let relays = hashtree_config::resolve_relays(
        &config.nostr.relays,
        Some(config.server.bind_address.as_str()),
    );
    let repos = fetch_git_repos(author, &relays).await?;
    if repos.is_empty() {
        println!("No git repos found for {}.", owner_display);
        return Ok(());
    }

    println!("Git repos for {}:", owner_display);
    for repo_name in repos {
        println!("  htree://{}/{}", owner_display, repo_name);
    }

    Ok(())
}

async fn fetch_git_repos(author: PublicKey, relays: &[String]) -> Result<Vec<String>> {
    let client = Client::default();

    for relay in relays {
        if let Err(err) = client.add_relay(relay).await {
            tracing::warn!("Failed to add relay {}: {}", relay, err);
        }
    }
    client.connect().await;

    wait_for_connected_relay(&client).await?;

    let filter = Filter::new()
        .kind(Kind::Custom(KIND_APP_DATA))
        .author(author)
        .custom_tag(SingleLetterTag::lowercase(Alphabet::L), LABEL_GIT)
        .limit(500);

    let events = match tokio::time::timeout(
        Duration::from_secs(3),
        client.fetch_events(filter, Duration::from_secs(3)),
    )
    .await
    {
        Ok(Ok(events)) => events.to_vec(),
        Ok(Err(err)) => {
            let _ = client.disconnect().await;
            return Err(anyhow::anyhow!(
                "Failed to fetch git repo events from relays: {}",
                err
            ));
        }
        Err(_) => {
            let _ = client.disconnect().await;
            return Err(anyhow::anyhow!(
                "Timed out fetching git repo events from relays"
            ));
        }
    };

    let _ = client.disconnect().await;

    let mut latest_by_repo: HashMap<String, (Timestamp, EventId)> = HashMap::new();
    for event in events {
        let Some(repo_name) = git_repo_name(&event) else {
            continue;
        };

        let entry = latest_by_repo
            .entry(repo_name.to_string())
            .or_insert((event.created_at, event.id));
        if (event.created_at, event.id) > (entry.0, entry.1) {
            *entry = (event.created_at, event.id);
        }
    }

    let mut repos: Vec<String> = latest_by_repo.into_keys().collect();
    repos.sort();
    Ok(repos)
}

async fn wait_for_connected_relay(client: &Client) -> Result<()> {
    let start = std::time::Instant::now();
    loop {
        let relays = client.relays().await;
        for relay in relays.values() {
            if relay.is_connected() {
                return Ok(());
            }
        }
        if start.elapsed() > Duration::from_secs(2) {
            let _ = client.disconnect().await;
            return Err(anyhow::anyhow!(
                "Failed to connect to any relay while listing repos"
            ));
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
}

fn git_repo_name(event: &Event) -> Option<&str> {
    let has_hashtree_label = event.tags.iter().any(|tag| {
        let slice = tag.as_slice();
        slice.len() >= 2 && slice[0].as_str() == "l" && slice[1].as_str() == LABEL_HASHTREE
    });
    let has_git_label = event.tags.iter().any(|tag| {
        let slice = tag.as_slice();
        slice.len() >= 2 && slice[0].as_str() == "l" && slice[1].as_str() == LABEL_GIT
    });
    if !has_hashtree_label || !has_git_label {
        return None;
    }

    event.tags.iter().find_map(|tag| {
        let slice = tag.as_slice();
        if slice.len() < 2 || slice[0].as_str() != "d" {
            return None;
        }
        let repo_name = slice[1].as_str();
        if repo_name.is_empty() {
            None
        } else {
            Some(repo_name)
        }
    })
}