use crate::console;
use crate::project::Project;
use std::path::{Path, PathBuf};
const LINK: &str = "public/storage";
const TARGET: &str = "storage/app/public";
pub fn link(project: &Project, args: &[String]) -> Result<(), String> {
let force = args.iter().any(|a| a == "--force" || a == "-f");
let root = project.root.as_path();
let link = root.join(LINK);
let target = root.join(TARGET);
std::fs::create_dir_all(&target)
.map_err(|e| format!("cannot create {}: {e}", display(&target, root)))?;
if let Some(parent) = link.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create {}: {e}", display(parent, root)))?;
}
match existing(&link) {
Existing::LinkTo(current) if same_place(¤t, &target) => {
console::success(&format!("{LINK} already points at {TARGET}"));
return Ok(());
}
Existing::LinkTo(current) if !force => {
return Err(format!(
"{LINK} is already a link, but it points at {}.\n \
Run `rustlavel storage:link --force` to repoint it.",
current.display()
));
}
Existing::LinkTo(_) => {
std::fs::remove_file(&link)
.map_err(|e| format!("cannot replace {LINK}: {e}"))?;
}
Existing::Directory => {
return Err(format!(
"{LINK} is a real directory, not a link, so it may hold files this \
command did not put there.\n \
Move or delete it yourself, then run this again — `--force` \
deliberately does not cover this case."
));
}
Existing::File => {
return Err(format!("{LINK} exists and is a file. Move it out of the way first."));
}
Existing::Nothing => {}
}
symlink(&target, &link)?;
console::success(&format!(
"Linked {LINK} to {TARGET}.\n\n Files written to the `public` disk are now served \
from /storage/…"
));
Ok(())
}
enum Existing {
Nothing,
LinkTo(PathBuf),
Directory,
File,
}
fn existing(path: &Path) -> Existing {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return Existing::Nothing;
};
if metadata.file_type().is_symlink() {
return Existing::LinkTo(std::fs::read_link(path).unwrap_or_default());
}
if metadata.is_dir() {
return Existing::Directory;
}
Existing::File
}
fn same_place(current: &Path, target: &Path) -> bool {
match (current.canonicalize(), target.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> Result<(), String> {
std::os::unix::fs::symlink(target, link)
.map_err(|e| format!("cannot link {LINK} to {TARGET}: {e}"))
}
#[cfg(windows)]
fn symlink(target: &Path, link: &Path) -> Result<(), String> {
std::os::windows::fs::symlink_dir(target, link).map_err(|e| {
format!(
"cannot link {LINK} to {TARGET}: {e}\n \
Windows only permits creating a directory symlink with Developer Mode \
turned on, or from an elevated prompt."
)
})
}
fn display(path: &Path, root: &Path) -> String {
path.strip_prefix(root).unwrap_or(path).display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn project(name: &str) -> (tempdir::Guard, Project) {
let guard = tempdir::create(name);
std::fs::create_dir_all(guard.path().join("public")).unwrap();
std::fs::write(guard.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let project =
Project { root: guard.path().to_path_buf(), crate_name: "x".to_string() };
(guard, project)
}
#[test]
fn creates_the_link_and_the_directory_it_points_at() {
let (guard, project) = project("creates");
link(&project, &[]).expect("the link should be made");
let path = guard.path().join(LINK);
assert!(std::fs::symlink_metadata(&path).unwrap().file_type().is_symlink());
assert!(guard.path().join(TARGET).is_dir(), "the target is created, not demanded");
}
#[test]
fn running_it_twice_is_not_an_error() {
let (_guard, project) = project("twice");
link(&project, &[]).expect("first");
link(&project, &[]).expect("second");
}
#[test]
fn a_file_written_through_the_link_is_reachable_from_public() {
let (guard, project) = project("reachable");
link(&project, &[]).unwrap();
std::fs::write(guard.path().join(TARGET).join("avatar.png"), b"pixels").unwrap();
let served = guard.path().join(LINK).join("avatar.png");
assert_eq!(std::fs::read(served).unwrap(), b"pixels");
}
#[test]
fn a_link_pointing_somewhere_else_is_refused_until_forced() {
let (guard, project) = project("elsewhere");
let elsewhere = guard.path().join("somewhere-else");
std::fs::create_dir_all(&elsewhere).unwrap();
std::fs::create_dir_all(guard.path().join("public")).unwrap();
std::os::unix::fs::symlink(&elsewhere, guard.path().join(LINK)).unwrap();
let error = link(&project, &[]).unwrap_err();
assert!(error.contains("--force"), "the error must say the way out: {error}");
link(&project, &["--force".to_string()]).expect("force should repoint it");
assert!(same_place(
&std::fs::read_link(guard.path().join(LINK)).unwrap(),
&guard.path().join(TARGET)
));
}
#[test]
fn a_real_directory_is_never_removed_even_with_force() {
let (guard, project) = project("real-directory");
let occupied = guard.path().join(LINK);
std::fs::create_dir_all(&occupied).unwrap();
std::fs::write(occupied.join("theirs.txt"), b"do not delete me").unwrap();
for arguments in [vec![], vec!["--force".to_string()]] {
let error = link(&project, &arguments).unwrap_err();
assert!(error.contains("real directory"), "got {error}");
}
assert!(occupied.join("theirs.txt").exists(), "their file survived");
}
#[test]
fn a_broken_link_is_repointed_rather_than_reported_as_missing() {
let (guard, project) = project("broken");
std::os::unix::fs::symlink(guard.path().join("gone"), guard.path().join(LINK)).unwrap();
let error = link(&project, &[]).unwrap_err();
assert!(error.contains("--force"), "got {error}");
link(&project, &["--force".to_string()]).expect("force repoints a broken link");
}
mod tempdir {
use std::path::{Path, PathBuf};
pub struct Guard(PathBuf);
impl Guard {
pub fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Guard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
pub fn create(name: &str) -> Guard {
let path = std::env::temp_dir().join(format!("rustlavel-storage-link-{name}"));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("a temporary directory");
Guard(path)
}
}
}