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;
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`"
),
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 slug = info.slug.clone();
let wrote = match existing {
None => {
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)
}
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
)
})
}
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)?;
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)
}
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)
}