1#![doc(
2 html_logo_url = "https://graphix-lang.github.io/graphix/graphix-icon.svg",
3 html_favicon_url = "https://graphix-lang.github.io/graphix/graphix-icon.svg"
4)]
5use ahash::AHashMap;
6use anyhow::{anyhow, bail, Context, Result};
7use arcstr::ArcStr;
8use async_trait::async_trait;
9use chrono::Local;
10use compact_str::{format_compact, CompactString};
11use crates_io_api::AsyncClient;
12use flate2::bufread::MultiGzDecoder;
13use graphix_compiler::{env::Env, expr::ExprId, ExecCtx};
14use graphix_rt::{CompExp, GXExt, GXHandle, GXRt};
15use handlebars::Handlebars;
16pub use indexmap::IndexSet;
17use netidx_value::Value;
18use reqwest::Url;
19use serde_json::json;
20use std::{
21 any::Any,
22 collections::{BTreeMap, BTreeSet},
23 path::{Path, PathBuf},
24 process::Stdio,
25 sync::mpsc as smpsc,
26 time::Duration,
27};
28use tokio::{
29 fs,
30 io::{AsyncBufReadExt, BufReader},
31 process::Command,
32 sync::oneshot,
33 task,
34};
35use walkdir::WalkDir;
36
37#[cfg(test)]
38mod test;
39
40#[derive(Clone)]
42pub struct MainThreadHandle(smpsc::Sender<Box<dyn FnOnce() + Send + 'static>>);
43
44impl MainThreadHandle {
45 pub fn new() -> (Self, smpsc::Receiver<Box<dyn FnOnce() + Send + 'static>>) {
46 let (tx, rx) = smpsc::channel();
47 (Self(tx), rx)
48 }
49
50 pub fn run(&self, f: Box<dyn FnOnce() + Send + 'static>) -> Result<()> {
51 self.0.send(f).map_err(|_| anyhow!("main thread receiver dropped"))
52 }
53}
54
55#[async_trait]
57pub trait CustomDisplay<X: GXExt>: Any {
58 async fn clear(&mut self);
67
68 async fn process_update(&mut self, env: &Env, id: ExprId, v: Value);
75}
76
77#[allow(async_fn_in_trait)]
79pub trait Package<X: GXExt> {
80 fn register(
87 ctx: &mut ExecCtx<GXRt<X>, X::UserEvent>,
88 modules: &mut AHashMap<netidx_core::path::Path, ArcStr>,
89 root_mods: &mut IndexSet<ArcStr>,
90 ) -> Result<()>;
91
92 fn is_custom(gx: &GXHandle<X>, env: &Env, e: &CompExp<X>) -> bool;
95
96 async fn init_custom(
109 gx: &GXHandle<X>,
110 env: &Env,
111 stop: oneshot::Sender<()>,
112 e: CompExp<X>,
113 run_on_main: MainThreadHandle,
114 ) -> Result<Box<dyn CustomDisplay<X>>>;
115
116 fn main_program() -> Option<&'static str>;
119}
120
121struct Skel {
123 version: &'static str,
124 cargo_toml: &'static str,
125 deps_rs: &'static str,
126 lib_rs: &'static str,
127 mod_gx: &'static str,
128 mod_gxi: &'static str,
129 readme_md: &'static str,
130}
131
132static SKEL: Skel = Skel {
133 version: env!("CARGO_PKG_VERSION"),
134 cargo_toml: include_str!("skel/Cargo.toml.hbs"),
135 deps_rs: include_str!("skel/deps.rs"),
136 lib_rs: include_str!("skel/lib.rs"),
137 mod_gx: include_str!("skel/mod.gx"),
138 mod_gxi: include_str!("skel/mod.gxi"),
139 readme_md: include_str!("skel/README.md"),
140};
141
142pub async fn create_package(base: &Path, name: &str) -> Result<()> {
148 if !fs::metadata(base).await?.is_dir() {
149 bail!("base path {base:?} does not exist, or is not a directory")
150 }
151 if name.contains(|c: char| c != '-' && !c.is_ascii_alphanumeric())
152 || !name.starts_with("graphix-package-")
153 {
154 bail!("invalid package name, name must match graphix-package-[-a-z]+")
155 }
156 let full_path = base.join(name);
157 if fs::metadata(&full_path).await.is_ok() {
158 bail!("package {name} already exists")
159 }
160 fs::create_dir_all(&full_path.join("src").join("graphix")).await?;
161 let mut hb = Handlebars::new();
162 hb.register_template_string("Cargo.toml", SKEL.cargo_toml)?;
163 hb.register_template_string("lib.rs", SKEL.lib_rs)?;
164 hb.register_template_string("mod.gx", SKEL.mod_gx)?;
165 hb.register_template_string("mod.gxi", SKEL.mod_gxi)?;
166 hb.register_template_string("README.md", SKEL.readme_md)?;
167 let name = name.strip_prefix("graphix-package-").unwrap();
168 let params = json!({"name": name, "deps": []});
169 fs::write(full_path.join("Cargo.toml"), hb.render("Cargo.toml", ¶ms)?).await?;
170 fs::write(full_path.join("README.md"), hb.render("README.md", ¶ms)?).await?;
171 let src = full_path.join("src");
172 fs::write(src.join("lib.rs"), hb.render("lib.rs", ¶ms)?).await?;
173 let graphix_src = src.join("graphix");
174 fs::write(&graphix_src.join("mod.gx"), hb.render("mod.gx", ¶ms)?).await?;
175 fs::write(&graphix_src.join("mod.gxi"), hb.render("mod.gxi", ¶ms)?).await?;
176 Ok(())
177}
178
179fn graphix_data_dir() -> Result<PathBuf> {
180 Ok(dirs::data_local_dir()
181 .ok_or_else(|| anyhow!("can't find your data dir"))?
182 .join("graphix"))
183}
184
185fn packages_toml_path() -> Result<PathBuf> {
186 Ok(graphix_data_dir()?.join("packages.toml"))
187}
188
189const DEFAULT_PACKAGES: &[(&str, &str)] = &[
191 ("core", SKEL.version),
192 ("array", SKEL.version),
193 ("str", SKEL.version),
194 ("map", SKEL.version),
195 ("sys", SKEL.version),
196 ("http", SKEL.version),
197 ("json", SKEL.version),
198 ("toml", SKEL.version),
199 ("pack", SKEL.version),
200 ("xls", SKEL.version),
201 ("sqlite", SKEL.version),
202 ("db", SKEL.version),
203 ("list", SKEL.version),
204 ("args", SKEL.version),
205 ("hbs", SKEL.version),
206 ("re", SKEL.version),
207 ("rand", SKEL.version),
208 ("tui", SKEL.version),
209 ("gui", SKEL.version),
210];
211
212fn is_stdlib_package(name: &str) -> bool {
213 DEFAULT_PACKAGES.iter().any(|(n, _)| *n == name)
214}
215
216#[derive(Debug, Clone)]
218pub enum PackageEntry {
219 Version(String),
220 Path(PathBuf),
221}
222
223impl std::fmt::Display for PackageEntry {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 match self {
226 Self::Version(v) => write!(f, "{v}"),
227 Self::Path(p) => write!(f, "path:{}", p.display()),
228 }
229 }
230}
231
232async fn read_packages() -> Result<BTreeMap<String, PackageEntry>> {
234 let path = packages_toml_path()?;
235 match fs::read_to_string(&path).await {
236 Ok(contents) => {
237 let doc: toml::Value =
238 toml::from_str(&contents).context("parsing packages.toml")?;
239 let tbl = doc
240 .get("packages")
241 .and_then(|v| v.as_table())
242 .ok_or_else(|| anyhow!("packages.toml missing [packages] table"))?;
243 let mut packages = BTreeMap::new();
244 for (k, v) in tbl {
245 let entry = match v {
246 toml::Value::String(s) => PackageEntry::Version(s.clone()),
247 toml::Value::Table(t) => {
248 if let Some(p) = t.get("path").and_then(|v| v.as_str()) {
249 PackageEntry::Path(PathBuf::from(p))
250 } else {
251 bail!("package {k}: table entry must have a 'path' key")
252 }
253 }
254 _ => bail!("package {k}: expected a version string or table"),
255 };
256 packages.insert(k.clone(), entry);
257 }
258 Ok(packages)
259 }
260 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
261 let packages: BTreeMap<String, PackageEntry> = DEFAULT_PACKAGES
262 .iter()
263 .map(|(k, v)| (k.to_string(), PackageEntry::Version(v.to_string())))
264 .collect();
265 write_packages(&packages).await?;
266 Ok(packages)
267 }
268 Err(e) => Err(e.into()),
269 }
270}
271
272async fn write_packages(packages: &BTreeMap<String, PackageEntry>) -> Result<()> {
274 let path = packages_toml_path()?;
275 if let Some(parent) = path.parent() {
276 fs::create_dir_all(parent).await?;
277 }
278 let mut doc = toml::value::Table::new();
279 let mut tbl = toml::value::Table::new();
280 for (k, entry) in packages {
281 match entry {
282 PackageEntry::Version(v) => {
283 tbl.insert(k.clone(), toml::Value::String(v.clone()));
284 }
285 PackageEntry::Path(p) => {
286 let mut t = toml::value::Table::new();
287 t.insert(
288 "path".to_string(),
289 toml::Value::String(p.to_string_lossy().into_owned()),
290 );
291 tbl.insert(k.clone(), toml::Value::Table(t));
292 }
293 }
294 }
295 doc.insert("packages".to_string(), toml::Value::Table(tbl));
296 fs::write(&path, toml::to_string_pretty(&doc)?).await?;
297 Ok(())
298}
299
300async fn graphix_version() -> Result<String> {
302 let graphix = which::which("graphix").context("can't find the graphix command")?;
303 let c = Command::new(&graphix).arg("--version").stdout(Stdio::piped()).spawn()?;
304 let line = BufReader::new(c.stdout.unwrap())
305 .lines()
306 .next_line()
307 .await?
308 .ok_or_else(|| anyhow!("graphix did not return a version"))?;
309 Ok(line.split_whitespace().last().unwrap_or(&line).to_string())
311}
312
313async fn extract_local_source(cargo: &Path, version: &str) -> Result<PathBuf> {
315 let graphix_build_dir = graphix_data_dir()?.join("build");
316 let graphix_dir = graphix_build_dir.join(format!("graphix-shell-{version}"));
317 match fs::metadata(&graphix_build_dir).await {
318 Err(_) => fs::create_dir_all(&graphix_build_dir).await?,
319 Ok(md) if !md.is_dir() => bail!("{graphix_build_dir:?} isn't a directory"),
320 Ok(_) => (),
321 }
322 match fs::metadata(&graphix_dir).await {
323 Ok(md) if !md.is_dir() => bail!("{graphix_dir:?} isn't a directory"),
324 Ok(_) => return Ok(graphix_dir),
325 Err(_) => (),
326 }
327 let package = format!("graphix-shell-{version}");
328 let cargo_root = cargo
329 .parent()
330 .ok_or_else(|| anyhow!("can't find cargo root"))?
331 .parent()
332 .ok_or_else(|| anyhow!("can't find cargo root"))?;
333 let cargo_src = cargo_root.join("registry").join("src");
334 match fs::metadata(&cargo_src).await {
335 Ok(md) if md.is_dir() => (),
336 Err(_) | Ok(_) => bail!("can't find cargo cache {cargo_src:?}"),
337 };
338 let r = task::spawn_blocking({
339 let graphix_dir = graphix_dir.clone();
340 move || -> Result<()> {
341 let src_path = WalkDir::new(&cargo_src)
342 .max_depth(2)
343 .into_iter()
344 .find_map(|e| {
345 let e = e.ok()?;
346 if e.file_type().is_dir() && e.path().ends_with(&package) {
347 return Some(e.into_path());
348 }
349 None
350 })
351 .ok_or_else(|| anyhow!("can't find {package} in {cargo_src:?}"))?;
352 cp_r::CopyOptions::new().copy_tree(&src_path, graphix_dir)?;
353 Ok(())
354 }
355 })
356 .await?;
357 match r {
358 Ok(()) => Ok(graphix_dir),
359 Err(e) => {
360 let _ = fs::remove_dir_all(&graphix_dir).await;
361 Err(e)
362 }
363 }
364}
365
366async fn download_source(
368 crates_io: &AsyncClient,
369 graphix_data_dir: &Path,
370 version: &str,
371) -> Result<PathBuf> {
372 let package = format!("graphix-shell-{version}");
373 let graphix_build_dir = graphix_data_dir.join("build");
374 let graphix_dir = graphix_build_dir.join(&package);
375 match fs::metadata(&graphix_build_dir).await {
376 Err(_) => fs::create_dir_all(&graphix_build_dir).await?,
377 Ok(md) if !md.is_dir() => bail!("{graphix_build_dir:?} isn't a directory"),
378 Ok(_) => (),
379 }
380 match fs::metadata(&graphix_dir).await {
381 Ok(md) if !md.is_dir() => bail!("{graphix_dir:?} isn't a directory"),
382 Ok(_) => return Ok(graphix_dir),
383 Err(_) => (),
384 }
385 let cr = crates_io.get_crate("graphix-shell").await?;
386 let cr_version = cr
387 .versions
388 .into_iter()
389 .find(|v| v.num == version)
390 .ok_or_else(|| anyhow!("can't find version {version} on crates.io"))?;
391 let crate_data_tar_gz =
392 reqwest::get(Url::parse("https://crates.io")?.join(&cr_version.dl_path)?)
393 .await?
394 .bytes()
395 .await?;
396 let r = task::spawn_blocking({
397 let graphix_build_dir = graphix_build_dir.clone();
398 let cargo_toml = graphix_dir.join("Cargo.toml");
399 move || -> Result<()> {
400 use std::io::Read;
401 let mut crate_data_tar = vec![];
402 MultiGzDecoder::new(&crate_data_tar_gz[..])
403 .read_to_end(&mut crate_data_tar)?;
404 tar::Archive::new(&mut &crate_data_tar[..]).unpack(&graphix_build_dir)?;
405 if !std::fs::exists(&cargo_toml)? {
406 bail!("package missing Cargo.toml")
407 }
408 Ok(())
409 }
410 })
411 .await?;
412 match r {
413 Ok(()) => Ok(graphix_dir),
414 Err(e) => {
415 let _ = fs::remove_dir_all(&graphix_dir).await;
416 Err(e)
417 }
418 }
419}
420
421#[derive(Debug, Clone)]
422pub struct PackageId {
423 name: CompactString,
424 version: Option<CompactString>,
425 path: Option<PathBuf>,
426}
427
428impl PackageId {
429 pub fn new(name: &str, version: Option<&str>) -> Self {
430 let name = if name.starts_with("graphix-package-") {
431 CompactString::from(name.strip_prefix("graphix-package-").unwrap())
432 } else {
433 CompactString::from(name)
434 };
435 let version = version.map(CompactString::from);
436 Self { name, version, path: None }
437 }
438
439 pub fn with_path(name: &str, path: PathBuf) -> Self {
440 let name = if name.starts_with("graphix-package-") {
441 CompactString::from(name.strip_prefix("graphix-package-").unwrap())
442 } else {
443 CompactString::from(name)
444 };
445 Self { name, version: None, path: Some(path) }
446 }
447
448 pub fn name(&self) -> &str {
450 &self.name
451 }
452
453 pub fn crate_name(&self) -> CompactString {
455 format_compact!("graphix-package-{}", self.name)
456 }
457
458 pub fn version(&self) -> Option<&str> {
459 self.version.as_ref().map(|s| s.as_str())
460 }
461
462 pub fn path(&self) -> Option<&Path> {
463 self.path.as_deref()
464 }
465}
466
467pub struct GraphixPM {
469 cratesio: AsyncClient,
470 cargo: PathBuf,
471}
472
473impl GraphixPM {
474 pub async fn new() -> Result<Self> {
476 let cargo = which::which("cargo").context("can't find the cargo command")?;
477 let cratesio = AsyncClient::new(
478 "Graphix Package Manager <eestokes@pm.me>",
479 Duration::from_secs(1),
480 )?;
481 Ok(Self { cratesio, cargo })
482 }
483
484 fn lock_file() -> Result<fd_lock::RwLock<std::fs::File>> {
487 let lock_path = graphix_data_dir()?.join("graphix.lock");
488 if let Some(parent) = lock_path.parent() {
489 std::fs::create_dir_all(parent)?;
490 }
491 let file = std::fs::OpenOptions::new()
492 .create(true)
493 .truncate(false)
494 .read(true)
495 .write(true)
496 .open(&lock_path)
497 .context("opening lock file")?;
498 Ok(fd_lock::RwLock::new(file))
499 }
500
501 async fn unpack_source(&self, version: &str) -> Result<PathBuf> {
505 let graphix_data_dir = graphix_data_dir()?;
506 match extract_local_source(&self.cargo, version).await {
507 Ok(p) => Ok(p),
508 Err(local) => {
509 match download_source(&self.cratesio, &graphix_data_dir, version).await {
510 Ok(p) => Ok(p),
511 Err(dl) => {
512 bail!("could not find our source local: {local}, dl: {dl}")
513 }
514 }
515 }
516 }
517 }
518
519 fn generate_deps_rs(
521 &self,
522 packages: &BTreeMap<String, PackageEntry>,
523 ) -> Result<String> {
524 let mut hb = Handlebars::new();
525 hb.register_template_string("deps.rs", SKEL.deps_rs)?;
526 let deps: Vec<serde_json::Value> = packages
527 .keys()
528 .map(|name| {
529 json!({
530 "crate_name": format!("graphix_package_{}", name.replace('-', "_")),
531 })
532 })
533 .collect();
534 let params = json!({ "deps": deps });
535 Ok(hb.render("deps.rs", ¶ms)?)
536 }
537
538 fn update_cargo_toml(
540 &self,
541 cargo_toml_content: &str,
542 packages: &BTreeMap<String, PackageEntry>,
543 ) -> Result<String> {
544 use toml_edit::DocumentMut;
545 let mut doc: DocumentMut =
546 cargo_toml_content.parse().context("parsing Cargo.toml")?;
547 let deps = doc["dependencies"]
548 .as_table_mut()
549 .ok_or_else(|| anyhow!("Cargo.toml missing [dependencies]"))?;
550 let to_remove: Vec<String> = deps
551 .iter()
552 .filter_map(|(k, _)| {
553 if k.starts_with("graphix-package-") {
554 Some(k.to_string())
555 } else {
556 None
557 }
558 })
559 .collect();
560 for k in to_remove {
561 deps.remove(&k);
562 }
563 for (name, entry) in packages {
564 let crate_name = format!("graphix-package-{name}");
565 match entry {
566 PackageEntry::Version(version) => {
567 deps[&crate_name] = toml_edit::value(version);
568 }
569 PackageEntry::Path(path) => {
570 let mut tbl = toml_edit::InlineTable::new();
571 tbl.insert(
572 "path",
573 toml_edit::Value::from(path.to_string_lossy().as_ref()),
574 );
575 deps[&crate_name] = toml_edit::Item::Value(tbl.into());
576 }
577 }
578 }
579 let dep_names: BTreeSet<String> =
581 deps.iter().map(|(k, _)| k.to_string()).collect();
582 if let Some(features) = doc.get_mut("features").and_then(|f| f.as_table_mut()) {
584 let mut empty_features = Vec::new();
585 for (feat, val) in features.iter_mut() {
586 if let Some(arr) = val.as_array_mut() {
587 arr.retain(|v| match v.as_str() {
588 Some(s) if s.starts_with("dep:graphix-package-") => {
589 dep_names.contains(&s["dep:".len()..])
590 }
591 Some(s) if s.starts_with("graphix-package-") => {
592 dep_names.contains(s)
593 }
594 _ => true,
595 });
596 if arr.is_empty() {
597 empty_features.push(feat.to_string());
598 }
599 }
600 }
601 for feat in &empty_features {
602 features.remove(feat);
603 }
604 if let Some(default) =
606 features.get_mut("default").and_then(|v| v.as_array_mut())
607 {
608 default.retain(|v| match v.as_str() {
609 Some(s) => !empty_features.contains(&s.to_string()),
610 _ => true,
611 });
612 }
613 }
614 Ok(doc.to_string())
615 }
616
617 async fn rebuild(
619 &self,
620 packages: &BTreeMap<String, PackageEntry>,
621 version: &str,
622 ) -> Result<()> {
623 println!("Unpacking graphix-shell source...");
624 let build_dir = graphix_data_dir()?.join("build");
626 if fs::metadata(&build_dir).await.is_ok() {
627 fs::remove_dir_all(&build_dir).await?;
628 }
629 let source_dir = self.unpack_source(version).await?;
630 println!("Generating deps.rs...");
632 let deps_rs = self.generate_deps_rs(&packages)?;
633 fs::write(source_dir.join("src").join("deps.rs"), &deps_rs).await?;
634 println!("Updating Cargo.toml...");
636 let cargo_toml_path = source_dir.join("Cargo.toml");
637 let cargo_toml_content = fs::read_to_string(&cargo_toml_path).await?;
638 let updated_cargo_toml =
639 self.update_cargo_toml(&cargo_toml_content, &packages)?;
640 fs::write(&cargo_toml_path, &updated_cargo_toml).await?;
641 if let Ok(graphix_path) = which::which("graphix") {
643 let date = Local::now().format("%Y%m%d-%H%M%S");
644 let backup_name = format!(
645 "graphix-previous-{date}{}",
646 graphix_path
647 .extension()
648 .map(|e| format!(".{}", e.to_string_lossy()))
649 .unwrap_or_default()
650 );
651 let backup_path = graphix_path.with_file_name(&backup_name);
652 let _ = fs::copy(&graphix_path, &backup_path).await;
653 }
654 println!("Building graphix with updated packages (this may take a while)...");
656 let status = Command::new(&self.cargo)
657 .arg("install")
658 .arg("--path")
659 .arg(&source_dir)
660 .arg("--force")
661 .status()
662 .await
663 .context("running cargo install")?;
664 if !status.success() {
665 bail!("cargo install failed with status {status}")
666 }
667 self.cleanup_old_binaries().await;
669 println!("Done! Restart graphix to use the updated packages.");
670 Ok(())
671 }
672
673 async fn cleanup_old_binaries(&self) {
675 let Ok(graphix_path) = which::which("graphix") else { return };
676 let Some(bin_dir) = graphix_path.parent() else { return };
677 let Ok(mut entries) = fs::read_dir(bin_dir).await else { return };
678 let week_ago =
679 std::time::SystemTime::now() - std::time::Duration::from_secs(7 * 24 * 3600);
680 while let Ok(Some(entry)) = entries.next_entry().await {
681 let name = entry.file_name();
682 let Some(name) = name.to_str() else { continue };
683 if !name.starts_with("graphix-previous-") {
684 continue;
685 }
686 if let Ok(md) = entry.metadata().await {
687 if let Ok(modified) = md.modified() {
688 if modified < week_ago {
689 let _ = fs::remove_file(entry.path()).await;
690 }
691 }
692 }
693 }
694 }
695
696 async fn read_package_version(path: &Path) -> Result<String> {
698 let cargo_toml_path = path.join("Cargo.toml");
699 let contents = fs::read_to_string(&cargo_toml_path)
700 .await
701 .with_context(|| format!("reading {}", cargo_toml_path.display()))?;
702 let doc: toml::Value =
703 toml::from_str(&contents).context("parsing package Cargo.toml")?;
704 doc.get("package")
705 .and_then(|p| p.get("version"))
706 .and_then(|v| v.as_str())
707 .map(|s| s.to_string())
708 .ok_or_else(|| anyhow!("no version found in {}", cargo_toml_path.display()))
709 }
710
711 pub async fn add_packages(
713 &self,
714 packages: &[PackageId],
715 skip_crates_io_check: bool,
716 ) -> Result<()> {
717 let mut lock = Self::lock_file()?;
718 let _guard = lock.write().context("waiting for package lock")?;
719 let mut installed = read_packages().await?;
720 let mut changed = false;
721 for pkg in packages {
722 let entry = if let Some(path) = pkg.path() {
723 let path = path
724 .canonicalize()
725 .with_context(|| format!("resolving path {}", path.display()))?;
726 let version = Self::read_package_version(&path).await?;
727 println!(
728 "Adding {} @ path {} (version {version})",
729 pkg.name(),
730 path.display()
731 );
732 PackageEntry::Path(path)
733 } else if skip_crates_io_check {
734 match pkg.version() {
735 Some(v) => {
736 println!("Adding {}@{v}", pkg.name());
737 PackageEntry::Version(v.to_string())
738 }
739 None => bail!(
740 "version is required for {} when using --skip-crates-io-check",
741 pkg.name()
742 ),
743 }
744 } else {
745 let crate_name = pkg.crate_name();
746 let cr =
747 self.cratesio.get_crate(&crate_name).await.with_context(|| {
748 format!("package {crate_name} not found on crates.io")
749 })?;
750 let version = match pkg.version() {
751 Some(v) => v.to_string(),
752 None => cr.crate_data.max_version.clone(),
753 };
754 println!("Adding {}@{version}", pkg.name());
755 PackageEntry::Version(version)
756 };
757 installed.insert(pkg.name().to_string(), entry);
758 changed = true;
759 }
760 if changed {
761 let version = graphix_version().await?;
762 self.rebuild(&installed, &version).await?;
763 write_packages(&installed).await?;
764 } else {
765 println!("No changes needed.");
766 }
767 Ok(())
768 }
769
770 pub async fn remove_packages(&self, packages: &[PackageId]) -> Result<()> {
772 let mut lock = Self::lock_file()?;
773 let _guard = lock.write().context("waiting for package lock")?;
774 let mut installed = read_packages().await?;
775 let mut changed = false;
776 for pkg in packages {
777 if pkg.name() == "core" {
778 eprintln!("Cannot remove the core package");
779 continue;
780 }
781 if installed.remove(pkg.name()).is_some() {
782 println!("Removing {}", pkg.name());
783 changed = true;
784 } else {
785 println!("{} is not installed", pkg.name());
786 }
787 }
788 if changed {
789 let version = graphix_version().await?;
790 self.rebuild(&installed, &version).await?;
791 write_packages(&installed).await?;
792 } else {
793 println!("No changes needed.");
794 }
795 Ok(())
796 }
797
798 pub async fn search(&self, query: &str) -> Result<()> {
800 let search_query = format!("graphix-package-{query}");
801 let results = self
802 .cratesio
803 .crates(crates_io_api::CratesQuery::builder().search(&search_query).build())
804 .await?;
805 if results.crates.is_empty() {
806 println!("No packages found matching '{query}'");
807 } else {
808 for cr in &results.crates {
809 let name = cr.name.strip_prefix("graphix-package-").unwrap_or(&cr.name);
810 let desc = cr.description.as_deref().unwrap_or("");
811 println!("{name} ({}) - {desc}", cr.max_version);
812 }
813 }
814 Ok(())
815 }
816
817 pub async fn do_rebuild(&self) -> Result<()> {
819 let mut lock = Self::lock_file()?;
820 let _guard = lock.write().context("waiting for package lock")?;
821 let packages = read_packages().await?;
822 let version = graphix_version().await?;
823 self.rebuild(&packages, &version).await
824 }
825
826 pub async fn list(&self) -> Result<()> {
828 let packages = read_packages().await?;
829 if packages.is_empty() {
830 println!("No packages installed");
831 } else {
832 for (name, version) in &packages {
833 println!("{name}: {version}");
834 }
835 }
836 Ok(())
837 }
838
839 pub async fn build_standalone(
845 &self,
846 package_dir: &Path,
847 source_override: Option<&Path>,
848 ) -> Result<()> {
849 let package_dir = package_dir
850 .canonicalize()
851 .with_context(|| format!("resolving {}", package_dir.display()))?;
852 let cargo_toml_path = package_dir.join("Cargo.toml");
854 let contents = fs::read_to_string(&cargo_toml_path)
855 .await
856 .with_context(|| format!("reading {}", cargo_toml_path.display()))?;
857 let doc: toml::Value =
858 toml::from_str(&contents).context("parsing package Cargo.toml")?;
859 let crate_name = doc
860 .get("package")
861 .and_then(|p| p.get("name"))
862 .and_then(|v| v.as_str())
863 .ok_or_else(|| anyhow!("no package name in {}", cargo_toml_path.display()))?;
864 let short_name =
865 crate_name.strip_prefix("graphix-package-").ok_or_else(|| {
866 anyhow!("package name must start with graphix-package-, got {crate_name}")
867 })?;
868 let mut packages = BTreeMap::new();
869 packages.insert(short_name.to_string(), PackageEntry::Path(package_dir.clone()));
870 packages
872 .insert("core".to_string(), PackageEntry::Version(SKEL.version.to_string()));
873 let mut lock_storage =
874 if source_override.is_none() { Some(Self::lock_file()?) } else { None };
875 let _guard = lock_storage
876 .as_mut()
877 .map(|l| l.write().context("waiting for package lock"))
878 .transpose()?;
879 let source_dir = if let Some(dir) = source_override {
880 dir.to_path_buf()
881 } else {
882 println!("Unpacking graphix-shell source...");
883 let build_dir = graphix_data_dir()?.join("build");
884 if fs::metadata(&build_dir).await.is_ok() {
885 fs::remove_dir_all(&build_dir).await?;
886 }
887 self.unpack_source(&graphix_version().await?).await?
888 };
889 println!("Generating deps.rs...");
890 let deps_rs = self.generate_deps_rs(&packages)?;
891 fs::write(source_dir.join("src").join("deps.rs"), &deps_rs).await?;
892 println!("Updating Cargo.toml...");
893 let shell_cargo_toml_path = source_dir.join("Cargo.toml");
894 let shell_cargo_toml = fs::read_to_string(&shell_cargo_toml_path).await?;
895 let updated = self.update_cargo_toml(&shell_cargo_toml, &packages)?;
896 fs::write(&shell_cargo_toml_path, &updated).await?;
897 println!("Building standalone binary (this may take a while)...");
898 let status = Command::new(&self.cargo)
899 .arg("build")
900 .arg("--release")
901 .arg("--features")
902 .arg(format!("{crate_name}/standalone"))
903 .current_dir(&source_dir)
904 .status()
905 .await
906 .context("running cargo build")?;
907 if !status.success() {
908 bail!("cargo build --release failed with status {status}")
909 }
910 let bin_name = format!("{short_name}{}", std::env::consts::EXE_SUFFIX);
911 let built = source_dir
912 .join("target")
913 .join("release")
914 .join(format!("graphix{}", std::env::consts::EXE_SUFFIX));
915 let dest = package_dir.join(&bin_name);
916 fs::copy(&built, &dest).await.with_context(|| {
917 format!("copying {} to {}", built.display(), dest.display())
918 })?;
919 println!("Done! Binary written to {}", dest.display());
920 Ok(())
921 }
922
923 async fn latest_version(&self, crate_name: &str) -> Result<String> {
925 let cr = self
926 .cratesio
927 .get_crate(crate_name)
928 .await
929 .with_context(|| format!("querying crates.io for {crate_name}"))?;
930 Ok(cr.crate_data.max_version)
931 }
932
933 pub async fn update(&self) -> Result<()> {
935 let mut lock = Self::lock_file()?;
936 let _guard = lock.write().context("waiting for package lock")?;
937 let current = graphix_version().await?;
938 let latest_shell = self.latest_version("graphix-shell").await?;
939 if current == latest_shell {
940 println!("graphix is already up to date (version {current})");
941 return Ok(());
942 }
943 println!("Updating graphix from {current} to {latest_shell}...");
944 let mut packages = read_packages().await?;
945 for (name, entry) in packages.iter_mut() {
946 if is_stdlib_package(name) {
947 if let PackageEntry::Version(_) = entry {
948 let crate_name = format!("graphix-package-{name}");
949 let latest = self.latest_version(&crate_name).await?;
950 println!(" {name}: {entry} -> {latest}");
951 *entry = PackageEntry::Version(latest);
952 }
953 }
954 }
955 self.rebuild(&packages, &latest_shell).await?;
956 write_packages(&packages).await?;
957 Ok(())
958 }
959}