use std::{
io::{IsTerminal, Write},
path::{Path, PathBuf},
};
use alix::{assemble::store_path_for, config::Config, state, store::Store, workspace};
use anyhow::{Context, Result, bail};
pub(crate) struct Target {
pub(crate) decks: Vec<PathBuf>,
pub(crate) default_store: Option<PathBuf>,
}
impl Target {
pub(crate) fn store_for_deck(&self, deck: &Path, cli_override: Option<&Path>) -> Result<Store> {
let path = cli_override
.map(Path::to_path_buf)
.or_else(|| store_path_for(std::slice::from_ref(&deck.to_path_buf()), None))
.or_else(|| self.default_store.clone());
match path {
Some(path) => state::open_store(deck, &path).map_err(Into::into),
None => {
let path = alix::store::default_store_path()
.context("cannot determine the data directory")?;
state::open_store(deck, &path).map_err(Into::into)
}
}
}
}
pub(crate) fn expand_target(path: &Path, config: &Config) -> Result<Target> {
if path.is_file() {
return Ok(Target {
decks: vec![path.to_path_buf()],
default_store: config.decks_dir().map(|d| workspace::root_store_path(&d)),
});
}
if !path.is_dir() {
bail!("`{}` is neither a deck file nor a folder", path.display());
}
let decks = workspace::deck_files(path);
if decks.is_empty() {
bail!("no decks in `{}`", path.display());
}
let default_store = if workspace::is_workspace(path) {
None } else {
Some(workspace::root_store_path(path))
};
Ok(Target {
decks,
default_store,
})
}
pub(crate) fn store_for(
decks: &[PathBuf],
cli_override: Option<PathBuf>,
config: &Config,
) -> Result<Store> {
let path = store_path_for(decks, cli_override.as_deref())
.or_else(|| config.decks_dir().map(|d| workspace::root_store_path(&d)));
let path = match path {
Some(path) => path,
None => alix::store::default_store_path().context("cannot determine the data directory")?,
};
state::open_stores(decks, &path).map_err(Into::into)
}
pub(crate) fn confirm(prompt: &str, yes: bool) -> Result<bool> {
if yes {
return Ok(true);
}
if !std::io::stdin().is_terminal() {
bail!("{prompt} (refusing without a terminal; pass --yes to proceed)");
}
print!("{prompt} [y/N] ");
std::io::stdout().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let answer = line.trim().to_lowercase();
Ok(answer == "y" || answer == "yes")
}
fn is_url(source: &str) -> bool {
source.starts_with("http://") || source.starts_with("https://")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct TreeSize {
pub(crate) files: usize,
pub(crate) bytes: u64,
}
impl TreeSize {
pub(crate) fn human_bytes(&self) -> String {
let b = self.bytes;
if b < 1_024 {
format!("{b} B")
} else if b < 1_024 * 1_024 {
format!("{:.1} KB", b as f64 / 1_024.0)
} else {
format!("{:.1} MB", b as f64 / (1_024.0 * 1_024.0))
}
}
}
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules"];
pub(crate) fn tree_size(root: &Path) -> TreeSize {
let mut files: usize = 0;
let mut bytes: u64 = 0;
walk(root, &mut files, &mut bytes);
TreeSize { files, bytes }
}
fn walk(dir: &Path, files: &mut usize, bytes: &mut u64) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
let name = entry.file_name();
if SKIP_DIRS.iter().any(|skip| name.to_str() == Some(skip)) {
continue;
}
walk(&path, files, bytes);
} else if meta.is_file() {
*files += 1;
*bytes += meta.len();
}
}
}
pub(crate) fn is_oversized(bytes: u64, threshold: u64) -> bool {
bytes > threshold
}
pub(crate) fn preflight_source(source: &str, threshold: u64, yes: bool) -> Result<()> {
if is_url(source) || threshold == 0 {
return Ok(());
}
let path = std::path::Path::new(source);
if !path.exists() {
return Ok(());
}
let size = tree_size(path);
if !is_oversized(size.bytes, threshold) {
return Ok(());
}
let msg = format!(
"source tree is {} files / {} — this may be a large model call",
size.files,
size.human_bytes()
);
if yes {
eprintln!("warning: {msg}; proceeding (--yes)");
return Ok(());
}
if !std::io::stdin().is_terminal() {
bail!(
"large source tree ({} files / {}); pass --yes to proceed",
size.files,
size.human_bytes()
);
}
print!("{msg}. Proceed? [y/N] ");
std::io::stdout().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let answer = line.trim().to_lowercase();
if answer != "y" && answer != "yes" {
bail!("aborted by user");
}
Ok(())
}
pub(crate) fn deck_out_dir(workspace: Option<&Path>, config: &Config) -> Result<PathBuf> {
match workspace {
Some(dir) => {
if !dir.is_dir() {
bail!(
"no folder at {} — create the workspace first: alix workspace init {}",
dir.display(),
dir.display()
);
}
if !workspace::has_manifest(dir) {
bail!(
"{} is not a workspace; create it first: alix workspace init {}",
dir.display(),
dir.display()
);
}
Ok(workspace::member_dir(dir))
}
None => config
.decks_dir()
.context("cannot determine the decks directory"),
}
}
pub(crate) fn one_line(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
#[test]
fn store_for_resolves_a_loose_deck_to_the_decks_dir_root_store() {
let dir = tempfile::tempdir().unwrap();
let deck = dir.path().join("loose.md");
std::fs::write(
&deck,
"---\nformat-version: 1\nid: deck-loose\n---\n## q <!-- id: card-q -->\na\n",
)
.unwrap();
let config = Config {
decks_dir: Some(dir.path().to_path_buf()),
..Default::default()
};
let store = store_for(std::slice::from_ref(&deck), None, &config).unwrap();
assert_eq!(
store.path(),
dir.path().join("progress/deck-loose.json").as_path()
);
}
#[test]
fn store_for_lets_a_cli_override_win_over_the_decks_dir_fallback() {
let dir = tempfile::tempdir().unwrap();
let deck = dir.path().join("loose.md");
std::fs::write(
&deck,
"---\nformat-version: 1\nid: deck-loose\n---\n## q <!-- id: card-q -->\na\n",
)
.unwrap();
let override_path = dir.path().join("custom");
let config = Config {
decks_dir: Some(dir.path().to_path_buf()),
..Default::default()
};
let store = store_for(
std::slice::from_ref(&deck),
Some(override_path.clone()),
&config,
)
.unwrap();
assert_eq!(
store.path(),
dir.path().join("custom/progress/deck-loose.json").as_path()
);
}
#[test]
fn store_for_still_resolves_a_workspace_deck_to_the_workspace_store() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("box");
std::fs::create_dir_all(ws.join(workspace::DECKS)).unwrap();
std::fs::write(ws.join("alix.toml"), "title = \"Box\"\n").unwrap();
let member = ws.join("decks/a.md");
std::fs::write(
&member,
"---\nformat-version: 1\nid: \"deck-a\"\n---\n## q <!-- id: card-q -->\na\n",
)
.unwrap();
let config = Config {
decks_dir: Some(dir.path().to_path_buf()),
..Default::default()
};
let store = store_for(std::slice::from_ref(&member), None, &config).unwrap();
assert_eq!(store.path(), ws.join("progress/deck-a.json").as_path());
}
#[test]
fn one_line_collapses_whitespace_runs_including_newlines() {
assert_eq!("a b c", one_line("a\n b\tc"));
}
#[test]
fn truncate_only_appends_an_ellipsis_when_the_text_is_cut() {
assert_eq!("hello", truncate("hello", 10));
assert_eq!("hel…", truncate("hello world", 4));
}
fn make_file(dir: &Path, name: &str, size: usize) {
fs::write(dir.join(name), vec![0u8; size]).unwrap();
}
#[test]
fn tree_size_counts_files_and_bytes() {
let dir = TempDir::new().unwrap();
make_file(dir.path(), "a.txt", 100);
make_file(dir.path(), "b.txt", 200);
let size = tree_size(dir.path());
assert_eq!(2, size.files);
assert_eq!(300, size.bytes);
}
#[test]
fn tree_size_recurses_into_subdirs() {
let dir = TempDir::new().unwrap();
let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap();
make_file(&sub, "c.txt", 50);
make_file(dir.path(), "root.txt", 10);
let size = tree_size(dir.path());
assert_eq!(2, size.files);
assert_eq!(60, size.bytes);
}
#[test]
fn tree_size_skips_git_target_node_modules() {
let dir = TempDir::new().unwrap();
make_file(dir.path(), "real.txt", 10);
for skip in [".git", "target", "node_modules"] {
let d = dir.path().join(skip);
fs::create_dir(&d).unwrap();
make_file(&d, "hidden.txt", 999);
}
let size = tree_size(dir.path());
assert_eq!(1, size.files);
assert_eq!(10, size.bytes);
}
#[test]
fn tree_size_is_zero_for_empty_dir() {
let dir = TempDir::new().unwrap();
let size = tree_size(dir.path());
assert_eq!(0, size.files);
assert_eq!(0, size.bytes);
}
#[test]
fn is_oversized_uses_strict_greater_than() {
assert!(!is_oversized(5_000_000, 5_000_000));
assert!(is_oversized(5_000_001, 5_000_000));
assert!(!is_oversized(0, 5_000_000));
}
#[test]
fn human_bytes_formats_correctly() {
assert_eq!(
"512 B",
TreeSize {
files: 1,
bytes: 512
}
.human_bytes()
);
assert_eq!(
"1.0 KB",
TreeSize {
files: 1,
bytes: 1_024
}
.human_bytes()
);
assert_eq!(
"1.0 MB",
TreeSize {
files: 1,
bytes: 1_024 * 1_024
}
.human_bytes()
);
assert_eq!(
"4.8 MB",
TreeSize {
files: 1,
bytes: 5_000_000
}
.human_bytes()
);
}
}