From 5b9a5bcaa46453d2059e34799c15e6ba60094210 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:40:33 +0200 Subject: [PATCH] refactor: add core archive opening APIs --- crates/archivr-cli/src/main.rs | 79 +++---------- crates/archivr-core/src/archive.rs | 184 +++++++++++++++++++++++++++++ crates/archivr-core/src/lib.rs | 1 + 3 files changed, 199 insertions(+), 65 deletions(-) create mode 100644 crates/archivr-core/src/archive.rs diff --git a/crates/archivr-cli/src/main.rs b/crates/archivr-cli/src/main.rs index 1279780..482b298 100644 --- a/crates/archivr-cli/src/main.rs +++ b/crates/archivr-cli/src/main.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use archivr_core::{database, downloader, twitter::parse_tweet_id}; +use archivr_core::{archive, database, downloader, twitter::parse_tweet_id}; use chrono::Local; use clap::{Parser, Subcommand}; use serde_json::json; @@ -53,19 +53,6 @@ enum Command { }, } -fn get_archive_path() -> Result> { - let mut dir = env::current_dir().context("failed to read current working directory")?; - loop { - if dir.join(".archivr").is_dir() { - return Ok(Some(dir.join(".archivr"))); - } - if !dir.pop() { - break; - } - } - Ok(None) -} - #[derive(Debug, PartialEq, Eq, Clone, Copy)] enum Source { YouTubeVideo, @@ -310,14 +297,6 @@ fn move_temp_to_raw(file: &Path, hash: &str, store_path: &Path) -> Result<()> { Ok(()) } -fn initialize_store_directories(store_path: &Path) -> Result<()> { - fs::create_dir_all(store_path.join("raw"))?; - fs::create_dir_all(store_path.join("raw_tweets"))?; - fs::create_dir_all(store_path.join("structured"))?; - fs::create_dir_all(store_path.join("temp"))?; - Ok(()) -} - fn raw_relative_path_from_hash(hash: &str, file_extension: &str) -> Result { let mut chars = hash.chars(); let first_letter = chars.next().context("hash must not be empty")?; @@ -609,7 +588,7 @@ fn main() -> Result<()> { match args.command { Command::Archive { ref path } => { - let archive_path = match get_archive_path()? { + let archive_path = match archive::find_archive_path()? { Some(path) => path, None => { eprintln!("Not in an archive. Use 'archivr init' to create one."); @@ -833,7 +812,7 @@ fn main() -> Result<()> { name: ref archive_name, force_with_info_removal, } => { - let archive_path = Path::new(&archive_path_string).join(".archivr"); + let archive_parent = Path::new(&archive_path_string); let store_path = if Path::new(&store_path_string).is_relative() { env::current_dir() .context("failed to read current working directory")? @@ -842,47 +821,17 @@ fn main() -> Result<()> { Path::new(store_path_string).to_path_buf() }; - if archive_path.exists() { - if !archive_path.is_dir() { - eprintln!( - "Archive path exists and is not a directory: {}", - archive_path.display() - ); - process::exit(1); - } - - if force_with_info_removal { - fs::remove_dir_all(&archive_path)?; - } else if fs::read_dir(&archive_path)?.next().is_some() { - eprintln!( - "Archive already exists at {} and is not empty. Use --force-with-info-removal to reinitialize.", - archive_path.display() - ); - process::exit(1); - } - } - - if store_path.exists() && !force_with_info_removal { - eprintln!("Store path already exists at {}", store_path.display()); - process::exit(1); - } - - fs::create_dir_all(&archive_path)?; - fs::create_dir_all(&store_path)?; - fs::write(archive_path.join("name"), archive_name)?; - fs::write( - archive_path.join("store_path"), - store_path - .canonicalize() - .with_context(|| format!("failed to canonicalize {}", store_path.display()))? - .to_str() - .context("store path is not valid UTF-8")?, + let paths = archive::initialize_archive( + archive_parent, + &store_path, + archive_name, + force_with_info_removal, )?; - initialize_store_directories(&store_path)?; - let conn = database::open_or_initialize(&archive_path)?; - let _ = database::ensure_default_user(&conn)?; - println!("Initialized empty archive in {}", archive_path.display()); + println!( + "Initialized empty archive in {}", + paths.archive_path.display() + ); Ok(()) } // _ => eprintln!("Unknown command: {:?}", args.command), @@ -1254,7 +1203,7 @@ mod tests { Local::now().format("%Y%m%d%H%M%S%3f") )); - initialize_store_directories(&store_path).unwrap(); + archive::initialize_store_directories(&store_path).unwrap(); assert!(store_path.join("raw").is_dir()); assert!(store_path.join("raw_tweets").is_dir()); @@ -1272,7 +1221,7 @@ mod tests { Local::now().format("%Y%m%d%H%M%S%3f") )); let _ = fs::remove_dir_all(&store_path); - initialize_store_directories(&store_path).unwrap(); + archive::initialize_store_directories(&store_path).unwrap(); fs::create_dir_all(store_path.join("raw").join("a").join("b")).unwrap(); fs::create_dir_all(store_path.join("raw").join("c").join("d")).unwrap(); fs::write( diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs new file mode 100644 index 0000000..d6df849 --- /dev/null +++ b/crates/archivr-core/src/archive.rs @@ -0,0 +1,184 @@ +use anyhow::{Context, Result, bail}; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use crate::database; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArchivePaths { + pub archive_path: PathBuf, + pub store_path: PathBuf, + pub name: String, +} + +pub fn find_archive_path_from(start: &Path) -> Result> { + let mut dir = start.to_path_buf(); + loop { + let candidate = dir.join(".archivr"); + if candidate.is_dir() { + return Ok(Some(candidate)); + } + if !dir.pop() { + return Ok(None); + } + } +} + +pub fn find_archive_path() -> Result> { + let cwd = env::current_dir().context("failed to read current working directory")?; + find_archive_path_from(&cwd) +} + +pub fn read_archive_paths(archive_path: &Path) -> Result { + if !archive_path.is_dir() { + bail!("archive path does not exist: {}", archive_path.display()); + } + + let name = fs::read_to_string(archive_path.join("name")) + .with_context(|| format!("failed to read archive name in {}", archive_path.display()))? + .trim() + .to_string(); + let store_path = fs::read_to_string(archive_path.join("store_path")) + .with_context(|| format!("failed to read store path in {}", archive_path.display()))?; + + Ok(ArchivePaths { + archive_path: archive_path.to_path_buf(), + store_path: PathBuf::from(store_path.trim()), + name, + }) +} + +pub fn initialize_archive( + archive_parent: &Path, + store_path: &Path, + archive_name: &str, + force_with_info_removal: bool, +) -> Result { + let archive_path = archive_parent.join(".archivr"); + + if archive_path.exists() { + if !archive_path.is_dir() { + bail!( + "Archive path exists and is not a directory: {}", + archive_path.display() + ); + } + + if force_with_info_removal { + fs::remove_dir_all(&archive_path)?; + } else if fs::read_dir(&archive_path)?.next().is_some() { + bail!( + "Archive already exists at {} and is not empty. Use --force-with-info-removal to reinitialize.", + archive_path.display() + ); + } + } + + if store_path.exists() && !force_with_info_removal { + bail!("Store path already exists at {}", store_path.display()); + } + + fs::create_dir_all(&archive_path)?; + fs::create_dir_all(store_path)?; + fs::write(archive_path.join("name"), archive_name)?; + let canonical_store_path = store_path + .canonicalize() + .with_context(|| format!("failed to canonicalize {}", store_path.display()))?; + fs::write( + archive_path.join("store_path"), + canonical_store_path + .to_str() + .context("store path is not valid UTF-8")?, + )?; + + initialize_store_directories(&canonical_store_path)?; + let conn = database::open_or_initialize(&archive_path)?; + let _ = database::ensure_default_user(&conn)?; + + Ok(ArchivePaths { + archive_path, + store_path: canonical_store_path, + name: archive_name.to_string(), + }) +} + +pub fn initialize_store_directories(store_path: &Path) -> Result<()> { + fs::create_dir_all(store_path.join("raw"))?; + fs::create_dir_all(store_path.join("raw_tweets"))?; + fs::create_dir_all(store_path.join("structured"))?; + fs::create_dir_all(store_path.join("temp"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_path(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{nanos}-{}", std::process::id())) + } + + #[test] + fn find_archive_path_walks_up_to_dot_archivr() { + let root = unique_path("archivr-core-find"); + let nested = root.join("a").join("b"); + fs::create_dir_all(root.join(".archivr")).unwrap(); + fs::create_dir_all(&nested).unwrap(); + + let found = find_archive_path_from(&nested).unwrap().unwrap(); + + assert_eq!(found, root.join(".archivr")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn read_archive_paths_returns_name_and_store_path() { + let root = unique_path("archivr-core-open"); + let archive_path = root.join(".archivr"); + let store_path = root.join("store"); + fs::create_dir_all(&archive_path).unwrap(); + fs::create_dir_all(&store_path).unwrap(); + fs::write(archive_path.join("name"), "Personal").unwrap(); + fs::write( + archive_path.join("store_path"), + store_path.display().to_string(), + ) + .unwrap(); + + let paths = read_archive_paths(&archive_path).unwrap(); + + assert_eq!(paths.archive_path, archive_path); + assert_eq!(paths.store_path, store_path); + assert_eq!(paths.name, "Personal"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn initialize_archive_creates_database_store_and_metadata() { + let root = unique_path("archivr-core-init"); + let archive_parent = root.join("archive"); + let store_path = root.join("store"); + + let paths = initialize_archive(&archive_parent, &store_path, "Personal", false).unwrap(); + + assert_eq!(paths.archive_path, archive_parent.join(".archivr")); + assert!( + paths + .archive_path + .join(database::DATABASE_FILE_NAME) + .is_file() + ); + assert!(paths.store_path.join("raw").is_dir()); + assert!(paths.store_path.join("raw_tweets").is_dir()); + assert!(paths.store_path.join("structured").is_dir()); + assert!(paths.store_path.join("temp").is_dir()); + let _ = fs::remove_dir_all(root); + } +} diff --git a/crates/archivr-core/src/lib.rs b/crates/archivr-core/src/lib.rs index 50e268b..f1114c1 100644 --- a/crates/archivr-core/src/lib.rs +++ b/crates/archivr-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod archive; pub mod database; pub mod downloader; pub mod hash;