semora-cli 1.1.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::local_links::{self, LocalLink};
use crate::project_link::Fit;
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::Link {
            dir,
            rewrite,
            project,
        } => link(ctx, dir, rewrite, project).await,
        ProjectCmd::Unlink { dir } => unlink(ctx, dir),
        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 config_dir = &ctx.settings.config_dir;
    // A `.semora` that is already here stops us when it is for another server (`resolve` says how
    // to go on) or names another id than `--name`. A worktree or a renamed clone keeps the id the
    // file names.
    let existing = match project_link::read_at(&dir)? {
        Some(file) => Some(project_link::resolve(&client, config_dir, file).await?),
        None => None,
    };
    if let Some(l) = &existing
        && name.as_ref().is_some_and(|n| *n != l.slug)
    {
        bail!(
            "{} already links this folder to project `{}`. Remove it to start again.",
            project_link::shown(&dir.join(project_link::FILE)),
            l.slug
        );
    }
    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()
        ),
    };
    // An old id the server still answers gives the project under its new one.
    let slug = info.slug.clone();
    let wrote = match existing {
        None => {
            // The id goes into a committed file, so it is asked for, not taken from the cache.
            let id = crate::server_id::of(&client, config_dir, true).await?;
            Some(project_link::write(&dir, &id, &slug)?)
        }
        Some(l) => {
            project_link::adopt(l, &slug);
            None
        }
    };
    if ctx.json {
        output::json(&info);
        return Ok(exit::OK);
    }
    let done = output::out().done;
    if created {
        anstream::println!("{done}Created{done:#} project \"{}\" ({slug})", info.name);
    } else if info.level == Level::Owner {
        anstream::println!(
            "{done}Linked{done:#} to your existing project \"{}\" ({slug})",
            info.name
        );
    } else {
        anstream::println!(
            "{done}Joined{done:#} 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.");
    }
    anstream::println!("{LINK}{}{LINK:#}", info.url, LINK = output::LINK);
    Ok(exit::OK)
}

/// The `.semora` that `dir` (default: the current folder) is in.
fn file_for(dir: Option<PathBuf>) -> anyhow::Result<project_link::FileLink> {
    let dir = dir.unwrap_or_else(|| PathBuf::from("."));
    project_link::find(&dir)?.with_context(|| {
        format!(
            "{} is not in a project: no {} here or in a parent folder.",
            project_link::shown(&dir),
            project_link::FILE
        )
    })
}

/// `project link`: uses this CLI's server for a `.semora` that names another one, through a
/// record on this machine, or with `rewrite` by changing the file.
async fn link(
    ctx: &Ctx,
    dir: Option<PathBuf>,
    rewrite: bool,
    project: Option<String>,
) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let config_dir = &ctx.settings.config_dir;
    let file = file_for(dir)?;
    let path = project_link::shown(&file.root.join(project_link::FILE));
    let fit = project_link::fit(&client, config_dir, &file).await?;
    if matches!(fit, Fit::Ours | Fit::LegacyOurs) {
        println!("{path} is already for your server.");
        return Ok(exit::OK);
    }
    let id = crate::server_id::of(&client, config_dir, true).await?;
    let key = project_link::server_value(&file.server);
    if rewrite {
        project_link::set_line(&file.root, "server", &id)?;
        // A link that used another project id (the file's was taken here) keeps using it.
        let mut moved = String::new();
        if let Fit::Mapped(record) = &fit
            && record.project != file.slug
        {
            project_link::set_line(&file.root, "project", &record.project)?;
            moved = format!(
                " Its project is now '{}', the id this machine used.",
                record.project
            );
        }
        local_links::remove(config_dir, &key, &file.slug)?;
        output::warn(format!(
            "{path} now names your server.{moved} If you commit it, everyone else's checkouts stop working until they log in to your server or link it. Commit it only if the whole project is moving."
        ));
        return Ok(exit::OK);
    }
    let project = project.unwrap_or_else(|| file.slug.clone());
    if !project_link::valid_slug(&project) {
        bail!(
            "`{project}` is not a valid project id: use lowercase letters, digits and dashes, up to 40 characters, starting with a letter or digit"
        );
    }
    let origin = client.origin();
    local_links::save(
        config_dir,
        LocalLink {
            file_server: key,
            file_project: file.slug.clone(),
            origin: origin.clone(),
            server_id: id,
            project: project.clone(),
            root: Some(file.root.clone()),
        },
    )?;
    output::warn(format!(
        "Linked {path}'s project '{}' to {origin} on this machine, as project '{project}'. Documents you push go to your server only, and drift from everyone else's copy. `semora project unlink` undoes it.",
        file.slug
    ));
    Ok(exit::OK)
}

/// `project unlink`: drops this machine's record for the `.semora` here.
fn unlink(ctx: &Ctx, dir: Option<PathBuf>) -> anyhow::Result<i32> {
    let file = file_for(dir)?;
    let path = project_link::shown(&file.root.join(project_link::FILE));
    let key = project_link::server_value(&file.server);
    if local_links::remove(&ctx.settings.config_dir, &key, &file.slug)? {
        println!("Dropped this machine's link for {path}.");
    } else {
        println!("{path} has no link on this machine.");
    }
    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)
}