···11+use std::path::PathBuf;
22+33+#[derive(Debug, clap::Parser)]
44+pub struct Args {
55+ // clap attr matches typst
66+ #[clap(long, env = "TYPST_PACKAGE_PATH", value_name = "DIR")]
77+ pub package_path: Option<PathBuf>,
88+ #[clap(long)]
99+ #[cfg_attr(feature = "git2", clap(hide = true))]
1010+ /// Path to the git binary
1111+ pub git: Option<PathBuf>,
1212+ #[clap(subcommand)]
1313+ pub command: Subcommand,
1414+}
1515+1616+#[derive(Debug, clap::Subcommand)]
1717+pub enum Subcommand {
1818+ /// Create a new local package
1919+ New {
2020+ /// The name of the local package
2121+ name: String,
2222+ },
2323+ /// Print the path of a local package
2424+ #[clap(name = "path")]
2525+ PrintPath {
2626+ /// The name of the local package
2727+ name: String
2828+ },
2929+ /// Increase the version of a local package by one
3030+ Bump {
3131+ /// The name of the local package
3232+ name: String,
3333+ /// Increase the major instead of the minor version
3434+ #[clap(long, conflicts_with = "patch")]
3535+ major: bool,
3636+ /// Increase the patch instead of the minor version
3737+ #[clap(long)]
3838+ patch: bool,
3939+ },
4040+}
+124
src/git.rs
···11+#[cfg(feature = "git2")]
22+use std::error::Error;
33+use std::path::Path;
44+#[cfg(not(feature = "git2"))]
55+use std::process::Command;
66+77+#[allow(unused)]
88+use snafu::{OptionExt as _, ResultExt as _, Whatever};
99+1010+#[cfg(feature = "git2")]
1111+type GitError = Box<dyn Error>;
1212+#[cfg(not(feature = "git2"))]
1313+type GitError = Whatever;
1414+1515+pub fn init(at: &Path, _git: &Path) -> Result<(), GitError> {
1616+ #[cfg(feature = "git2")]
1717+ git2::Repository::init_opts(
1818+ at,
1919+ git2::RepositoryInitOptions::new()
2020+ .no_reinit(true)
2121+ .mkdir(false)
2222+ .mkpath(false)
2323+ .external_template(false),
2424+ )?;
2525+2626+ #[cfg(not(feature = "git2"))]
2727+ run(at, _git, ["init", "-q"], []).whatever_context("failed to run `git init`")?;
2828+2929+ Ok(())
3030+}
3131+3232+pub fn commit(at: &Path, files: &str, msg: &str, _git: &Path, _root: bool) -> Result<(), Whatever> {
3333+ #[cfg(feature = "git2")]
3434+ {
3535+ let repo = git2::Repository::open(at).whatever_context("failed to access repository")?;
3636+ let mut ind = repo.index().whatever_context("failed to get index")?;
3737+ ind.add_all([files].iter(), git2::IndexAddOption::DEFAULT, None)
3838+ .whatever_context("failed to add files to index")?;
3939+ ind.write().whatever_context("failed to write index")?;
4040+ let oid = ind
4141+ .write_tree()
4242+ .whatever_context("failed to write index tree")?;
4343+ let tree = repo.find_tree(oid).whatever_context("failed to get tree")?;
4444+ let sig = repo
4545+ .signature()
4646+ .whatever_context("failed to obtain signature")?;
4747+ let parent = (!_root)
4848+ .then(|| {
4949+ repo.head()
5050+ .whatever_context("failed to get HEAD")?
5151+ .peel_to_commit()
5252+ .whatever_context("failed to get HEAD commit")
5353+ })
5454+ .transpose()?;
5555+ repo.commit(
5656+ Some("HEAD"),
5757+ &sig,
5858+ &sig,
5959+ msg,
6060+ &tree,
6161+ parent.as_ref().as_slice(),
6262+ )
6363+ .whatever_context("failed to create commit")?;
6464+ }
6565+6666+ #[cfg(not(feature = "git2"))]
6767+ {
6868+ run(at, _git, ["add", files], []).whatever_context("failed to run `git add *`")?;
6969+ run(at, _git, ["commit", "-qm", msg], []).whatever_context("failed to run `git commit`")?;
7070+ }
7171+7272+ Ok(())
7373+}
7474+7575+pub fn tag_and_worktree(at: &Path, tag: &str, _git: &Path) -> Result<(), Whatever> {
7676+ #[cfg(feature = "git2")]
7777+ {
7878+ let repo = git2::Repository::open(at).whatever_context("failed to access repository")?;
7979+ let head = repo
8080+ .head()
8181+ .whatever_context("failed to get HEAD")?
8282+ .peel(git2::ObjectType::Any)
8383+ .whatever_context("failed to get HEAD object")?;
8484+ repo.tag_lightweight(&format!("v{tag}"), &head, false)
8585+ .whatever_context("failed to create tag")?;
8686+8787+ repo.worktree(tag, &at.join(tag), None)
8888+ .whatever_context("failed to create worktree")?;
8989+ }
9090+9191+ #[cfg(not(feature = "git2"))]
9292+ {
9393+ run(at, _git, ["tag", &format!("v{tag}")], [])
9494+ .whatever_context("failed to run `git tag`")?;
9595+ run(
9696+ at,
9797+ _git,
9898+ ["worktree", "add", "-q", tag, &format!("v{tag}")],
9999+ [],
100100+ )
101101+ .whatever_context("failed to run `git worktree add`")?;
102102+ }
103103+104104+ Ok(())
105105+}
106106+107107+#[cfg(not(feature = "git2"))]
108108+fn run<const N: usize, const M: usize>(
109109+ at: &Path,
110110+ git: &Path,
111111+ args: [&str; N],
112112+ other_args: [&Path; M],
113113+) -> Result<(), Whatever> {
114114+ Command::new(git)
115115+ .args(args)
116116+ .args(other_args)
117117+ .current_dir(at)
118118+ .status()
119119+ .whatever_context("couldn't execute command")?
120120+ .success()
121121+ .then_some(())
122122+ .whatever_context("command failed")?;
123123+ Ok(())
124124+}
+210
src/main.rs
···11+use std::path::{Component, Path};
22+33+use args::Args;
44+use args::Subcommand::*;
55+use clap::Parser;
66+use itertools::Itertools;
77+use snafu::{OptionExt as _, ResultExt as _, Whatever, whatever};
88+99+mod args;
1010+mod git;
1111+1212+fn typst_toml_template(name: &str) -> String {
1313+ format!(
1414+ "\
1515+[package]
1616+name = {name:?}
1717+version = \"0.1.0\"
1818+entrypoint = \"lib.typ\"
1919+"
2020+ )
2121+}
2222+2323+#[snafu::report]
2424+fn main() -> Result<(), Whatever> {
2525+ let Args {
2626+ package_path,
2727+ git,
2828+ command,
2929+ } = Args::parse();
3030+ let packages_local = package_path
3131+ .or_else(|| {
3232+ // matches what typst-kit does
3333+ dirs::data_dir().map(|d| d.join("typst/packages"))
3434+ })
3535+ .whatever_context("failed to get `packages` directory")?
3636+ .join("local");
3737+ #[cfg(feature = "git2")]
3838+ if git.is_some() {
3939+ eprintln!(
4040+ "warning: this binary was compiled with the `git2` feature, \
4141+ so the `--git` option has no effect."
4242+ );
4343+ }
4444+ let git = git.unwrap_or_else(|| "git".into());
4545+ if !packages_local.exists() {
4646+ fs_err::create_dir_all(&packages_local)
4747+ .whatever_context("failed to create `packages/local` directory")?;
4848+ }
4949+5050+ match command {
5151+ New { name } => {
5252+ verify_name(&name)?;
5353+ let dir = packages_local.join(&name);
5454+ if dir.exists() {
5555+ whatever!("a local package called {name:?} already exists");
5656+ }
5757+ fs_err::create_dir(&dir).whatever_context("failed to create package directory")?;
5858+ git::init(&dir, &git).whatever_context("failed to create repository")?;
5959+ fs_err::write(dir.join(".gitignore"), "/*.*.*\n")
6060+ .whatever_context("failed to create .gitignore")?;
6161+ fs_err::write(dir.join("typst.toml"), typst_toml_template(&name))
6262+ .whatever_context("failed to create typst.toml")?;
6363+ fs_err::File::create(dir.join("lib.typ"))
6464+ .whatever_context("failed to create lib.typ")?;
6565+ symlink_dir(&dir, &dir.join("0.1.0"))
6666+ .whatever_context("failed to create latest version symlink")?;
6767+ git::commit(&dir, "*", "Initial commit", &git, true)
6868+ .whatever_context("failed to create initial commit")?;
6969+ eprintln!("created local package at {dir:?}");
7070+ eprintln!("you can import it with `#import \"@local/{name}:0.1.0\"`");
7171+ }
7272+ PrintPath { name } => {
7373+ verify_name(&name)?;
7474+ let dir = packages_local.join(&name);
7575+ if !dir.exists() {
7676+ whatever!("no local package {name:?} found");
7777+ }
7878+ println!("{}", dir.display());
7979+ }
8080+ Bump { name, major, patch } => {
8181+ verify_name(&name)?;
8282+ let dir = packages_local.join(&name);
8383+ if !dir.exists() {
8484+ whatever!("no local package {name:?} found");
8585+ }
8686+ let typst_toml = dir.join("typst.toml");
8787+ let mut ver =
8888+ extract_version(&typst_toml).whatever_context("failed to get package version")?;
8989+9090+ let old_ver = format!("{}.{}.{}", ver[0], ver[1], ver[2]);
9191+ let old_path = dir.join(&old_ver);
9292+9393+ // confirm that old_path is actually a symlink to dir
9494+ if !old_path.exists() {
9595+ whatever!("no version symlink for {old_ver:?}");
9696+ }
9797+ let meta = fs_err::symlink_metadata(&old_path)
9898+ .whatever_context("failed to get metadata for version symlink")?;
9999+ if !meta.is_symlink() {
100100+ whatever!("{old_path:?} is not a symlink");
101101+ }
102102+ if fs_err::canonicalize(
103103+ fs_err::read_link(&old_path).whatever_context("failed to read version symlink")?,
104104+ )
105105+ .whatever_context("failed to canonicalize version symlink target")?
106106+ != fs_err::canonicalize(&dir)
107107+ .whatever_context("failed to canonicalize package dir")?
108108+ {
109109+ whatever!("{old_path:?} doesn't target {dir:?}");
110110+ }
111111+112112+ // increase version
113113+ let i = if major {
114114+ 0
115115+ } else if patch {
116116+ 2
117117+ } else {
118118+ 1
119119+ };
120120+ ver[i] += 1;
121121+ for x in &mut ver[i + 1..] {
122122+ *x = 0;
123123+ }
124124+125125+ let new_ver = format!("{}.{}.{}", ver[0], ver[1], ver[2]);
126126+ fs_err::rename(dir.join(&old_ver), dir.join(&new_ver))
127127+ .whatever_context("failed to move version symlink")?;
128128+ git::tag_and_worktree(&dir, &old_ver, &git)?;
129129+ write_version(&typst_toml, &new_ver)
130130+ .whatever_context("failed to write version to typst.toml")?;
131131+ git::commit(&dir, "typst.toml", "Bump version", &git, false)
132132+ .whatever_context("failed to commit typst.toml")?;
133133+ eprintln!("bumped version of local package {name:?} from {old_ver} to {new_ver}.");
134134+ }
135135+ }
136136+137137+ Ok(())
138138+}
139139+140140+fn extract_version(typst_toml: &Path) -> Result<[u32; 3], Whatever> {
141141+ let file = fs_err::read_to_string(typst_toml).whatever_context("failed to read file")?;
142142+ let pkg = toml::from_str::<toml::Table>(&file).whatever_context("failed to parse file")?;
143143+ let ver = pkg
144144+ .get("package")
145145+ .whatever_context("no `[package]` table")?
146146+ .as_table()
147147+ .whatever_context("`package` is not a table")?
148148+ .get("version")
149149+ .whatever_context("`[package]` table has no `version` key")?
150150+ .as_str()
151151+ .whatever_context("`package.version` is not a string")?;
152152+ parse_version(ver).whatever_context("invalid `package.version`")
153153+}
154154+155155+fn write_version(typst_toml: &Path, to: &str) -> Result<(), Whatever> {
156156+ let file = fs_err::read_to_string(typst_toml).whatever_context("failed to read file")?;
157157+ let mut pkg = file
158158+ .parse::<toml_edit::DocumentMut>()
159159+ .whatever_context("failed to parse file")?;
160160+ let ver = pkg
161161+ .get_mut("package")
162162+ .whatever_context("no `[package]` table")?
163163+ .as_table_like_mut()
164164+ .whatever_context("`package` is not a table")?
165165+ .get_mut("version")
166166+ .whatever_context("`[package]` table has no `version` key")?;
167167+ *ver = toml_edit::Item::Value(toml_edit::Value::String(toml_edit::Formatted::new(
168168+ to.to_string(),
169169+ )));
170170+ fs_err::write(typst_toml, pkg.to_string()).whatever_context("failed to write file")
171171+}
172172+173173+fn parse_version(v: &str) -> Result<[u32; 3], Whatever> {
174174+ let mut res = [0; 3];
175175+ let mut i = 0;
176176+ for c in v.split('.') {
177177+ if i == 3 {
178178+ whatever!("more than 3 periods");
179179+ }
180180+ res[i] = c
181181+ .parse()
182182+ .with_whatever_context(|_| format!("{c:?} is not a valid number"))?;
183183+ i += 1;
184184+ }
185185+ if i < 3 {
186186+ whatever!("fewer than 3 periods");
187187+ }
188188+ Ok(res)
189189+}
190190+191191+fn verify_name(name: &str) -> Result<(), Whatever> {
192192+ if !Path::new(&name)
193193+ .components()
194194+ .exactly_one()
195195+ .is_ok_and(|x| matches!(x, Component::Normal(_)))
196196+ {
197197+ whatever!("invalid file name: {name:?}");
198198+ }
199199+ Ok(())
200200+}
201201+202202+fn symlink_dir(original: &Path, link: &Path) -> Result<(), std::io::Error> {
203203+ #[cfg(unix)]
204204+ fs_err::os::unix::fs::symlink(original, link)?;
205205+206206+ #[cfg(windows)]
207207+ fs_err::os::windows::fs::symlink_dir(original, link)?;
208208+209209+ Ok(())
210210+}