voltaic-cli 0.1.1

The Voltaic compiler: .sdx in, .xrs containing ordinary Rust out.
use crate::build_command::build_command;

use notify::EventKind;

use notify::RecursiveMode;

use notify::Watcher;

use notify::event::ModifyKind;

use std::path::Path;

use std::path::PathBuf;

use std::sync::mpsc;

pub(crate) fn watch_command(args: &[String]) -> Result<(), String> {
  let input = args.iter().find(|a| !a.starts_with('-')).ok_or_else(|| {
    "error: no input file\n\n`voltaic watch` takes a .sdx file or a directory.".to_string()
  })?;
  let target = Target::resolve(Path::new(input))?;
  let plain = args.iter().any(|__rdx_1| __rdx_1.eq(&"--plain"));

  report(&target, plain);

  let channel = mpsc::channel();
  let tx = channel.0;
  let rx = channel.1;
  let mut watcher = notify::recommended_watcher(move |event| drop(tx.send(event)))
    .map_err(|e| format!("error: cannot start the file watcher: {}", e))?;

  let dir_shown = target.dir.display().to_string();
  let watched = watcher.watch(&target.dir, RecursiveMode::Recursive);
  watched.map_err(|e| format!("error: cannot watch {}: {}", dir_shown, e))?;

  eprintln!("watching {} — press ctrl-c to stop", dir_shown);

  for received in rx {
    match received {
      Ok(event) => {
        if is_change(&event.kind) && event.paths.iter().any(|p| is_relevant(p)) {
          report(&target, plain);
        }
      }
      Err(e) => eprintln!("error: the file watcher reported: {}", e),
    };
  }

  Ok(())
}

pub(crate) fn is_change(kind: &EventKind) -> bool {
  match kind {
    EventKind::Create(_) | EventKind::Remove(_) => true,
    EventKind::Any | EventKind::Other => true,
    EventKind::Modify(inner) => match inner {
      ModifyKind::Data(_) | ModifyKind::Name(_) => true,
      _ => false,
    },
    EventKind::Access(_) => false,
  }
}

pub(crate) fn is_relevant(path: &Path) -> bool {
  let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else {
    return false;
  };

  if name == "4913"
    || name.ends_with('~')
    || name.starts_with(".#")
    || name.starts_with('#') && name.ends_with('#')
  {
    return false;
  }

  let extension = path.extension().map(|e| e.to_string_lossy().to_string());
  match extension.as_deref() {
    Some("sdx") => !name.starts_with('.'),
    _ => false,
  }
}

pub(crate) fn report(target: &Target, plain: bool) {
  match &target.file {
    Some(file) => {
      let shown = file.display().to_string();
      run_build(&shown, plain)
    }
    None => {
      for source in sources(&target.dir) {
        run_build(&source, plain);
      }
    }
  };
  ();
}

pub(crate) fn run_build(input: &str, plain: bool) {
  let mut args = vec![input.to_string()];
  if plain {
    args.push("--plain".to_string());
  }

  match build_command(&args) {
    Ok(_) => eprintln!("built {}", input),
    Err(message) => eprintln!("{}", message),
  };
  ();
}

pub(crate) fn sources(dir: &Path) -> Vec<String> {
  let Ok(entries) = std::fs::read_dir(dir) else {
    return Vec::new();
  };
  let mut found: Vec<String> = entries
    .filter_map(Result::ok)
    .map(|e| e.path())
    .filter(|p| p.is_file() && p.extension().is_some_and(|x| x == "sdx"))
    .map(|p| p.display().to_string())
    .collect();
  found.sort();
  found
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Target {
  pub(crate) dir: PathBuf,
  pub(crate) file: Option<PathBuf>,
}

impl Target {
  pub(crate) fn resolve(arg: &Path) -> Result<Self, String> {
    if arg.is_dir() {
      return Ok(Target {
        dir: arg.to_path_buf(),
        file: None,
      });
    }

    if !arg.exists() {
      let shown = arg.display().to_string();
      return Err(format!(
        "error: {} does not exist\n\n`voltaic watch` takes a .sdx file or a directory of them.",
        shown
      ));
    }

    let parent = arg.parent().filter(|p| !p.as_os_str().is_empty());
    let dir = parent.unwrap_or(Path::new(".")).to_path_buf();
    Ok(Target {
      dir,
      file: Some(arg.to_path_buf()),
    })
  }
}

#[cfg(test)]
#[repr(transparent)]
struct Dir(PathBuf);

#[cfg(test)]
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);

#[cfg(test)]
impl Dir {
  fn new(tag: &str) -> Self {
    let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let pid = std::process::id();
    let dir = std::env::temp_dir().join(format!("voltaic-target-{}-{}-{}", tag, pid, n));
    std::fs::create_dir_all(&dir).expect("should create");
    Dir(dir)
  }
}

#[cfg(test)]
impl Drop for Dir {
  fn drop(&mut self) {
    let _dropped = std::fs::remove_dir_all(&self.0).ok();
    ();
  }
}

#[cfg(test)]
mod __rdx_tests {
  use super::*;

  #[test]
  fn reading_a_file_is_not_a_change() {
    assert!(!is_change(&EventKind::Access(
      notify::event::AccessKind::Open(notify::event::AccessMode::Any)
    )));
    assert!(!is_change(&EventKind::Access(
      notify::event::AccessKind::Read
    )));
    assert!(!is_change(&EventKind::Access(
      notify::event::AccessKind::Close(notify::event::AccessMode::Read)
    )));
    assert!(!is_change(&EventKind::Access(
      notify::event::AccessKind::Any
    )));
  }

  #[test]
  fn writing_data_is_a_change() {
    assert!(is_change(&EventKind::Modify(ModifyKind::Data(
      notify::event::DataChange::Any
    ))));
    assert!(is_change(&EventKind::Modify(ModifyKind::Data(
      notify::event::DataChange::Content
    ))));
  }

  #[test]
  fn renaming_is_a_change() {
    assert!(is_change(&EventKind::Modify(ModifyKind::Name(
      notify::event::RenameMode::To
    ))));
    assert!(is_change(&EventKind::Modify(ModifyKind::Name(
      notify::event::RenameMode::Both
    ))));
  }

  #[test]
  fn creating_and_removing_are_changes() {
    assert!(is_change(&EventKind::Create(
      notify::event::CreateKind::File
    )));
    assert!(is_change(&EventKind::Remove(
      notify::event::RemoveKind::File
    )));
  }

  #[test]
  fn metadata_alone_is_not_a_change() {
    assert!(!is_change(&EventKind::Modify(ModifyKind::Metadata(
      notify::event::MetadataKind::Any
    ))));
    assert!(!is_change(&EventKind::Modify(ModifyKind::Metadata(
      notify::event::MetadataKind::AccessTime
    ))));
  }

  #[test]
  fn an_unknown_kind_is_treated_as_a_change() {
    assert!(is_change(&EventKind::Any));
    assert!(is_change(&EventKind::Other));
  }

  #[test]
  fn a_sdx_file_is_relevant() {
    assert!(is_relevant(Path::new("src/main.sdx")));
    assert!(is_relevant(Path::new("main.sdx")));
  }

  #[test]
  fn another_extension_is_not() {
    for name in ["main.rs", "main.xrs", "Cargo.toml", "README.md", "main"] {
      assert!(!is_relevant(Path::new(name)), "{}", name);
    }
  }

  #[test]
  fn the_generated_output_is_not_relevant() {
    assert!(!is_relevant(Path::new("main.xrs")));
    assert!(!is_relevant(Path::new("main.xrs.map")));
  }

  #[test]
  fn editor_temporaries_are_ignored() {
    for name in [
      "4913",
      "main.sdx~",
      ".main.sdx.swp",
      ".#main.sdx",
      "#main.sdx#",
      ".main.sdx",
    ] {
      assert!(!is_relevant(Path::new(name)), "{} should be ignored", name);
    }
  }

  #[test]
  fn a_path_with_no_file_name_is_not_relevant() {
    assert!(!is_relevant(Path::new("/")));
    assert!(!is_relevant(Path::new("..")));
  }

  #[test]
  fn an_unfamiliar_name_still_compiles_if_it_is_a_sdx() {
    assert!(is_relevant(Path::new("weird-editor-artifact.sdx")));
  }

  #[test]
  fn finds_and_sorts_sdx_files() {
    let pid = std::process::id();
    let dir = std::env::temp_dir().join(format!("voltaic-watch-src-{}", pid));
    std::fs::create_dir_all(&dir).expect("should create");
    for name in ["b.sdx", "a.sdx", "ignored.rs"] {
      std::fs::write(
        dir.join(name),
        "main : ()\nmain =\n  println! \\-> \"#{1}\"\n",
      )
      .expect("should write");
    }

    let found = sources(&dir);
    assert_eq!(found.len(), 2, "{:?}", found);
    assert!(
      found[0].ends_with("a.sdx") && found[1].ends_with("b.sdx"),
      "{:?}",
      found
    );

    let _dropped = std::fs::remove_dir_all(&dir).ok();
  }

  #[test]
  fn an_unreadable_directory_yields_nothing() {
    let found = sources(std::path::Path::new("/nonexistent/voltaic"));
    assert!(found.is_empty());
  }

  #[test]
  fn a_directory_is_watched_whole() {
    let dir = Dir::new("whole");
    let target = Target::resolve(&dir.0).expect("should resolve");
    assert_eq!(target.dir, dir.0);
    assert_eq!(target.file, None, "a directory compiles all of itself");
  }

  #[test]
  fn a_file_is_watched_through_its_parent() {
    let dir = Dir::new("parent");
    let file = dir.0.join("main.sdx");
    std::fs::write(&file, "main : ()\nmain =\n  println! \\-> \"#{1}\"\n").expect("should write");

    let target = Target::resolve(&file).expect("should resolve");
    assert_eq!(target.dir, dir.0, "the parent directory is what is watched");
    assert_eq!(target.file, Some(file));
  }

  #[test]
  fn a_missing_path_is_an_error_that_says_what_is_accepted() {
    let err = Target::resolve(Path::new("/nonexistent/main.sdx")).expect_err("should fail");
    assert!(err.contains("does not exist"), "{}", err);
    assert!(err.contains(".sdx file or a directory"), "{}", err);
  }

  #[test]
  fn a_bare_filename_resolves_to_the_current_directory() {
    let dir = Dir::new("bare");
    let file = dir.0.join("main.sdx");
    std::fs::write(&file, "main : ()\nmain =\n  println! \\-> \"#{1}\"\n").expect("should write");

    let previous = std::env::current_dir().expect("a cwd");
    std::env::set_current_dir(&dir.0).expect("should chdir");
    let target = Target::resolve(Path::new("main.sdx")).expect("should resolve");
    std::env::set_current_dir(previous).expect("should restore");

    assert_eq!(target.dir, Path::new("."), "not the empty path");
  }
}