semora-cli 0.7.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! `semora project init`, `semora project ls`, and `semora project map` (in `map.rs`).

use std::path::PathBuf;

use anyhow::{Context, bail};
use semora_proto::{CreateProject, Level};

use crate::args::ProjectCmd;
use crate::client::ProjectCreated;
use crate::{Ctx, exit, output, project_link};

pub async fn run(ctx: &Ctx, cmd: ProjectCmd) -> anyhow::Result<i32> {
    match cmd {
        ProjectCmd::Init { dir, name, join } => init(ctx, dir, name, join).await,
        ProjectCmd::Ls => ls(ctx).await,
        ProjectCmd::Map(a) => super::map::run(ctx, a).await,
    }
}

async fn init(
    ctx: &Ctx,
    dir: Option<PathBuf>,
    name: Option<String>,
    join: bool,
) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let dir = std::fs::canonicalize(dir.unwrap_or_else(|| PathBuf::from(".")))
        .context("cannot read the project folder")?;
    if !dir.is_dir() {
        bail!("{} is not a folder", project_link::shown(&dir));
    }
    let dir_name = dir
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    if let Some(n) = &name
        && !project_link::valid_slug(n)
    {
        bail!(
            "`{n}` is not a valid project id: use lowercase letters, digits and dashes, up to 40 characters, starting with a letter or digit"
        );
    }
    let server = client.origin();
    let existing = project_link::read_at(&dir)?;
    // A `.semora` that is already here only stops us when it is for another server or names
    // another id than `--name`. A worktree or a renamed clone keeps the id the file names.
    if let Some(l) = &existing
        && (l.server != server || name.as_ref().is_some_and(|n| *n != l.slug))
    {
        bail!(
            "{} already links this folder to project `{}` on {}. Remove it to start again.",
            project_link::shown(&dir.join(project_link::FILE)),
            l.slug,
            l.server
        );
    }
    let slug = match (name, &existing) {
        (Some(n), _) => n,
        (None, Some(l)) => l.slug.clone(),
        (None, None) => project_link::slug_from(&dir_name).with_context(|| {
            format!("can't make a project id from `{dir_name}`; pass one with --name")
        })?,
    };
    let req = CreateProject {
        slug: slug.clone(),
        name: Some(dir_name).filter(|n| !n.trim().is_empty()),
        join,
    };
    let (info, created) = match client.create_project(&req).await? {
        ProjectCreated::Created(p) => (p, true),
        ProjectCreated::Linked(p) => (p, false),
        ProjectCreated::Taken => bail!(
            "the project id `{slug}` belongs to someone else; choose another with --name, for example `--name {slug}-2`"
        ),
        // Someone added you to their project; publishing into it is your choice.
        ProjectCreated::JoinNeeded { owner, level } => bail!(
            "Project `{slug}` belongs to {}, and you are {} {} in it. Run `semora project init --join` to publish into it, or choose another id with --name.",
            output::clean_name(&owner),
            if level == Level::Editor { "an" } else { "a" },
            level.label()
        ),
    };
    let wrote = if existing.is_none() {
        Some(project_link::write(&dir, &server, &slug)?)
    } else {
        None
    };
    if ctx.json {
        output::json(&info);
        return Ok(exit::OK);
    }
    if created {
        println!("Created project \"{}\" ({slug})", info.name);
    } else if info.level == Level::Owner {
        println!("Linked to your existing project \"{}\" ({slug})", info.name);
    } else {
        println!(
            "Joined project \"{}\" ({slug}) as {} {}",
            info.name,
            if info.level == Level::Editor {
                "an"
            } else {
                "a"
            },
            info.level.label()
        );
    }
    if let Some(file) = wrote {
        println!("Wrote {}", project_link::shown(&file));
        println!("Commit it so every checkout publishes to the same project.");
    }
    println!("{}", info.url);
    Ok(exit::OK)
}

async fn ls(ctx: &Ctx) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let projects = client.projects().await?;
    if ctx.json {
        output::json(&projects);
        return Ok(exit::OK);
    }
    if projects.is_empty() {
        println!("No projects. Run `semora project init` in a folder.");
        return Ok(exit::OK);
    }
    for p in &projects {
        println!(
            "{:<24} {:<9} {:>4} docs  {}  {}",
            p.slug,
            p.level.as_str(),
            p.documents,
            output::ts(p.updated_at),
            p.name
        );
    }
    Ok(exit::OK)
}