typst local package (tlp) manager
1use std::path::{Component, Path};
2
3use args::Args;
4use args::Subcommand::*;
5use clap::Parser;
6use itertools::Itertools;
7use snafu::{OptionExt as _, ResultExt as _, Whatever, whatever};
8use std::env;
9
10mod args;
11mod git;
12
13fn typst_toml_template(name: &str) -> String {
14 format!(
15 "\
16[package]
17name = {name:?}
18version = \"0.1.0\"
19entrypoint = \"lib.typ\"
20"
21 )
22}
23
24#[snafu::report]
25fn main() -> Result<(), Whatever> {
26 let Args {
27 package_path,
28 git,
29 command,
30 } = Args::parse();
31 let packages_local = package_path
32 .or_else(|| {
33 // matches what typst-kit does
34 dirs::data_dir().map(|d| d.join("typst/packages"))
35 })
36 .whatever_context("failed to get `packages` directory")?
37 .join("local");
38 let current_dir =
39 env::current_dir().whatever_context("failed to get you current working directory")?;
40 #[cfg(feature = "git2")]
41 if git.is_some() {
42 eprintln!(
43 "warning: this binary was compiled with the `git2` feature, \
44 so the `--git` option has no effect."
45 );
46 }
47 let git = git.unwrap_or_else(|| "git".into());
48 if !packages_local.exists() {
49 fs_err::create_dir_all(&packages_local)
50 .whatever_context("failed to create `packages/local` directory")?;
51 }
52
53 match command {
54 New { name } => {
55 verify_name(&name)?;
56 println!("The current directory is {}", current_dir.display());
57 let dir = current_dir.join(&name);
58 if dir.exists() {
59 whatever!("a local directory called {name:?} already exists");
60 }
61 let pkg_dir = packages_local.join(&name);
62 if pkg_dir.exists() {
63 whatever!("a local package called {name:?} already exists");
64 }
65 fs_err::create_dir(&dir).whatever_context("failed to create local directory")?;
66 fs_err::create_dir(&pkg_dir).whatever_context("failed to create package directory")?;
67 git::init(&dir, &git).whatever_context("failed to create repository")?;
68 fs_err::write(dir.join(".gitignore"), "/*.*.*\n")
69 .whatever_context("failed to create .gitignore")?;
70 fs_err::write(dir.join("typst.toml"), typst_toml_template(&name))
71 .whatever_context("failed to create typst.toml")?;
72 fs_err::File::create(dir.join("lib.typ"))
73 .whatever_context("failed to create lib.typ")?;
74 symlink_dir(&dir, &pkg_dir.join("source"))
75 .whatever_context("failed to create latest version symlink")?;
76 symlink_dir(&dir, &pkg_dir.join("0.1.0"))
77 .whatever_context("failed to create latest version symlink")?;
78 git::commit(&dir, "*", "Initial commit", &git, true)
79 .whatever_context("failed to create initial commit")?;
80 eprintln!("created local package at {dir:?}");
81 eprintln!("which is linked to {pkg_dir:?}");
82 eprintln!("you can import it with `#import \"@local/{name}:0.1.0\"`");
83 }
84 Init {} => {
85 println!("The current directory is {}", current_dir.display());
86 let typst_toml = current_dir.join("typst.toml");
87 let name = extract_name(&typst_toml).whatever_context("failed to get package name")?;
88 verify_name(&name)?;
89 let ver =
90 extract_version(&typst_toml).whatever_context("failed to get package version")?;
91 let current_ver = format!("{}.{}.{}", ver[0], ver[1], ver[2]);
92 let pkg_dir = packages_local.join(&name);
93 if pkg_dir.exists() {
94 whatever!("a local package called {name:?} already exists");
95 }
96 fs_err::create_dir(&pkg_dir).whatever_context("failed to create package directory")?;
97 if !current_dir.join(".git").exists() {
98 eprintln!("Initializing git in the current repo!");
99 git::init(¤t_dir, &git).whatever_context("failed to create repository")?;
100 git::commit(¤t_dir, "*", "Initial commit", &git, true)
101 .whatever_context("failed to create initial commit")?;
102 }
103 symlink_dir(¤t_dir, &pkg_dir.join("source"))
104 .whatever_context("failed to create latest version symlink")?;
105 symlink_dir(¤t_dir, &pkg_dir.join(¤t_ver))
106 .whatever_context("failed to create latest version symlink")?;
107 eprintln!("linked current directory to {pkg_dir:?}");
108 eprintln!("you can import it with `#import \"@local/{name}:{current_ver}\"`");
109 }
110 PrintPath { name } => {
111 verify_name(&name)?;
112 let pkg_dir = packages_local.join(&name);
113 if !pkg_dir.exists() {
114 whatever!("no local package {name:?} found");
115 }
116 if !pkg_dir.join("source").is_symlink() {
117 whatever!("could not find the source code!")
118 }
119 let dir = pkg_dir
120 .join("source")
121 .canonicalize()
122 .whatever_context("could not get the source code!");
123 println!("{}", dir?.display());
124 }
125 Bump { name, major, patch } => {
126 verify_name(&name)?;
127 let pkg_dir = packages_local.join(&name);
128 if !pkg_dir.exists() {
129 whatever!("no local package {name:?} found");
130 }
131 let dir = pkg_dir
132 .join("source")
133 .canonicalize()
134 .whatever_context("can not find the source path")?;
135 let typst_toml = dir.join("typst.toml");
136 let mut ver =
137 extract_version(&typst_toml).whatever_context("failed to get package version")?;
138
139 let old_ver = format!("{}.{}.{}", ver[0], ver[1], ver[2]);
140 let old_path = pkg_dir.join(&old_ver);
141
142 // confirm that old_path is actually a symlink to dir
143 if !old_path.exists() {
144 whatever!("no version symlink for {old_ver:?}");
145 }
146 let meta = fs_err::symlink_metadata(&old_path)
147 .whatever_context("failed to get metadata for version symlink")?;
148 if !meta.is_symlink() {
149 whatever!("{old_path:?} is not a symlink");
150 }
151 if fs_err::canonicalize(
152 fs_err::read_link(&old_path).whatever_context("failed to read version symlink")?,
153 )
154 .whatever_context("failed to canonicalize version symlink target")?
155 != fs_err::canonicalize(&dir)
156 .whatever_context("failed to canonicalize package dir")?
157 {
158 whatever!("{old_path:?} doesn't target {dir:?}");
159 }
160
161 // increase version
162 let i = if major {
163 0
164 } else if patch {
165 2
166 } else {
167 1
168 };
169 ver[i] += 1;
170 for x in &mut ver[i + 1..] {
171 *x = 0;
172 }
173
174 let new_ver = format!("{}.{}.{}", ver[0], ver[1], ver[2]);
175 fs_err::rename(pkg_dir.join(&old_ver), pkg_dir.join(&new_ver))
176 .whatever_context("failed to move version symlink")?;
177 git::tag_and_worktree(&dir, &pkg_dir, &old_ver, &git)?;
178 write_version(&typst_toml, &new_ver)
179 .whatever_context("failed to write version to typst.toml")?;
180 git::commit(&dir, "typst.toml", "Bump version", &git, false)
181 .whatever_context("failed to commit typst.toml")?;
182 eprintln!("bumped version of local package {name:?} from {old_ver} to {new_ver}.");
183 }
184 Remove { name } => {
185 verify_name(&name)?;
186 let pkg_dir = packages_local.join(&name);
187 if !pkg_dir.exists() {
188 whatever!("no local package {name:?} found");
189 }
190 if !pkg_dir.join("source").is_symlink() {
191 whatever!(
192 "could not find the source code! Might not be added by tlp, remove aborted."
193 )
194 }
195 fs_err::remove_dir_all(pkg_dir)
196 .whatever_context("failed to remove the package directory")?;
197 println!("Removed local package: {name:?}");
198 }
199 }
200
201 Ok(())
202}
203
204fn extract_name(typst_toml: &Path) -> Result<String, Whatever> {
205 let file = fs_err::read_to_string(typst_toml)
206 .whatever_context("could not find a typst.toml file the current directory")?;
207 let pkg = toml::from_str::<toml::Table>(&file).whatever_context("failed to parse file")?;
208 let name = pkg
209 .get("package")
210 .whatever_context("no `[package]` table")?
211 .as_table()
212 .whatever_context("`package` is not a table")?
213 .get("name")
214 .whatever_context("`[package]` table has no `name` key")?
215 .as_str()
216 .whatever_context("`package.name` is not a string")?;
217 // name.whatever_context("invalid `package.version`")
218 Ok(name.to_string())
219}
220
221fn extract_version(typst_toml: &Path) -> Result<[u32; 3], Whatever> {
222 let file = fs_err::read_to_string(typst_toml).whatever_context("failed to read file")?;
223 let pkg = toml::from_str::<toml::Table>(&file).whatever_context("failed to parse file")?;
224 let ver = pkg
225 .get("package")
226 .whatever_context("no `[package]` table")?
227 .as_table()
228 .whatever_context("`package` is not a table")?
229 .get("version")
230 .whatever_context("`[package]` table has no `version` key")?
231 .as_str()
232 .whatever_context("`package.version` is not a string")?;
233 parse_version(ver).whatever_context("invalid `package.version`")
234}
235
236fn write_version(typst_toml: &Path, to: &str) -> Result<(), Whatever> {
237 let file = fs_err::read_to_string(typst_toml).whatever_context("failed to read file")?;
238 let mut pkg = file
239 .parse::<toml_edit::DocumentMut>()
240 .whatever_context("failed to parse file")?;
241 let ver = pkg
242 .get_mut("package")
243 .whatever_context("no `[package]` table")?
244 .as_table_like_mut()
245 .whatever_context("`package` is not a table")?
246 .get_mut("version")
247 .whatever_context("`[package]` table has no `version` key")?;
248 *ver = toml_edit::Item::Value(toml_edit::Value::String(toml_edit::Formatted::new(
249 to.to_string(),
250 )));
251 fs_err::write(typst_toml, pkg.to_string()).whatever_context("failed to write file")
252}
253
254fn parse_version(v: &str) -> Result<[u32; 3], Whatever> {
255 let mut res = [0; 3];
256 let mut i = 0;
257 for c in v.split('.') {
258 if i == 3 {
259 whatever!("more than 3 periods");
260 }
261 res[i] = c
262 .parse()
263 .with_whatever_context(|_| format!("{c:?} is not a valid number"))?;
264 i += 1;
265 }
266 if i < 3 {
267 whatever!("fewer than 3 periods");
268 }
269 Ok(res)
270}
271
272fn verify_name(name: &str) -> Result<(), Whatever> {
273 if !Path::new(&name)
274 .components()
275 .exactly_one()
276 .is_ok_and(|x| matches!(x, Component::Normal(_)))
277 {
278 whatever!("invalid file name: {name:?}");
279 }
280 Ok(())
281}
282
283fn symlink_dir(original: &Path, link: &Path) -> Result<(), std::io::Error> {
284 #[cfg(unix)]
285 fs_err::os::unix::fs::symlink(original, link)?;
286
287 #[cfg(windows)]
288 fs_err::os::windows::fs::symlink_dir(original, link)?;
289
290 Ok(())
291}