use std::path::{Path, PathBuf};
use std::{collections::BTreeMap, fs, io};
use super::UploadFile;
#[derive(Debug, thiserror::Error)]
pub enum SelectError {
#[error("cannot read {}", path.display())]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("{} is not valid UTF-8", path.display())]
NotUtf8 {
path: PathBuf,
},
#[error("{} is outside {}", path.display(), root.display())]
Outside {
path: PathBuf,
root: PathBuf,
},
#[error("{} and {} both map to {path_in_repo} in the repo", first.display(), second.display())]
Clash {
first: PathBuf,
second: PathBuf,
path_in_repo: String,
},
}
const CARD: &str = "README.md";
pub fn select(output: &Path) -> Result<Vec<UploadFile>, SelectError> {
let mut chosen: BTreeMap<String, UploadFile> = BTreeMap::new();
walk(output, output, &mut chosen)?;
Ok(chosen.into_values().collect())
}
fn walk(
root: &Path,
dir: &Path,
chosen: &mut BTreeMap<String, UploadFile>,
) -> Result<(), SelectError> {
let read = |path: &Path| {
let path = path.to_path_buf();
move |source| SelectError::Read { path, source }
};
let entries = fs::read_dir(dir).map_err(read(dir))?;
for entry in entries {
let entry = entry.map_err(read(dir))?;
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
let at_root = dir == root;
if name.starts_with('.') {
continue;
}
let meta = fs::symlink_metadata(&path).map_err(read(&path))?;
if meta.is_dir() {
if at_root && name.starts_with("checkpoint-") {
continue;
}
walk(root, &path, chosen)?;
} else if meta.is_file() {
if at_root && name == "debug.log" {
continue;
}
let path_in_repo = repo_path(root, &path)?;
if path_in_repo == CARD {
continue;
}
let file = UploadFile {
local: path.clone(),
path_in_repo: path_in_repo.clone(),
size: meta.len(),
};
if let Some(other) = chosen.insert(path_in_repo.clone(), file) {
return Err(SelectError::Clash {
first: other.local,
second: path,
path_in_repo,
});
}
}
}
Ok(())
}
fn repo_path(root: &Path, path: &Path) -> Result<String, SelectError> {
let relative = path.strip_prefix(root).map_err(|_| SelectError::Outside {
path: path.to_path_buf(),
root: root.to_path_buf(),
})?;
let mut parts = Vec::new();
for component in relative.components() {
let part = component
.as_os_str()
.to_str()
.ok_or_else(|| SelectError::NotUtf8 {
path: path.to_path_buf(),
})?;
parts.push(part);
}
if parts.len() > 1 && parts.first() == Some(&"gguf") {
parts.remove(0);
}
Ok(parts.join("/"))
}
#[cfg(test)]
mod tests {
use std::error::Error;
use super::*;
fn tree(files: &[&str]) -> Result<tempfile::TempDir, Box<dyn Error>> {
let dir = tempfile::tempdir()?;
for f in files {
let path = dir.path().join(f);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, "data")?;
}
Ok(dir)
}
fn paths(dir: &Path) -> Result<Vec<String>, Box<dyn Error>> {
Ok(select(dir)?.into_iter().map(|f| f.path_in_repo).collect())
}
#[test]
fn layout_flattens_gguf_and_keeps_merged() -> Result<(), Box<dyn Error>> {
let dir = tree(&[
"adapter_config.json",
"adapter_model.safetensors",
"README.md",
"debug.log",
"checkpoint-48/adapter_model.safetensors",
".hidden",
"merged/config.json",
"merged/model.safetensors",
"gguf/run-Q4_K_M.gguf",
"gguf/Modelfile",
])?;
assert_eq!(
paths(dir.path())?,
vec![
"Modelfile",
"adapter_config.json",
"adapter_model.safetensors",
"merged/config.json",
"merged/model.safetensors",
"run-Q4_K_M.gguf"
]
);
Ok(())
}
#[test]
fn sizes_come_from_the_files() -> Result<(), Box<dyn Error>> {
let dir = tempfile::tempdir()?;
fs::write(dir.path().join("a.bin"), b"12345")?;
let files = select(dir.path())?;
assert_eq!(files.len(), 1);
assert_eq!(files[0].size, 5);
assert_eq!(files[0].local, dir.path().join("a.bin"));
Ok(())
}
#[test]
fn a_name_clash_after_flattening_is_an_error() -> Result<(), Box<dyn Error>> {
let dir = tree(&["Modelfile", "gguf/Modelfile"])?;
let err = select(dir.path()).err().ok_or("expected a clash")?;
let text = format!("{err:#}");
assert!(text.contains("map to Modelfile"), "names the clashing path");
let ours = dir.path().join("Modelfile");
let theirs = dir.path().join("gguf/Modelfile");
assert!(
text.contains(&ours.display().to_string()),
"names the root Modelfile: {text}"
);
assert!(
text.contains(&theirs.display().to_string()),
"names the gguf Modelfile: {text}"
);
Ok(())
}
#[test]
fn nothing_may_land_on_the_card() -> Result<(), Box<dyn Error>> {
let dir = tree(&["adapter_config.json", "gguf/README.md", "gguf/m.gguf"])?;
assert_eq!(paths(dir.path())?, vec!["adapter_config.json", "m.gguf"]);
Ok(())
}
#[test]
fn nested_gguf_keeps_its_relative_path_and_hidden_is_skipped_at_depth()
-> Result<(), Box<dyn Error>> {
let dir = tree(&[
"gguf/a/b.gguf",
"merged/README.md",
"merged/.cache/x",
"merged/checkpoint-1/y",
"checkpoint-2/z",
"sub/debug.log",
])?;
assert_eq!(
paths(dir.path())?,
vec![
"a/b.gguf",
"merged/README.md",
"merged/checkpoint-1/y",
"sub/debug.log"
]
);
Ok(())
}
#[cfg(unix)]
#[test]
fn symlinks_are_skipped() -> Result<(), Box<dyn Error>> {
let dir = tree(&["real.txt"])?;
std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link.txt"))?;
assert_eq!(paths(dir.path())?, vec!["real.txt"]);
Ok(())
}
}