typst local package (tlp) manager
at main 291 lines 12 kB view raw
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(&current_dir, &git).whatever_context("failed to create repository")?; 100 git::commit(&current_dir, "*", "Initial commit", &git, true) 101 .whatever_context("failed to create initial commit")?; 102 } 103 symlink_dir(&current_dir, &pkg_dir.join("source")) 104 .whatever_context("failed to create latest version symlink")?; 105 symlink_dir(&current_dir, &pkg_dir.join(&current_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}