#![deny(missing_docs)]
#![deny(clippy::missing_docs_in_private_items)]
#![deny(clippy::missing_errors_doc, clippy::missing_panics_doc)]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice
)
)]
use std::fs;
use std::io;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use magi_rs::headless::HeadlessError;
const MAGI_DIR_NAME: &str = ".magi";
const DB_FILE_NAME: &str = ".magi-rs-memory.db";
const CONFIG_FILE_NAME: &str = "magi.toml";
const LOGS_DIR_NAME: &str = "logs";
const SYMLINK_COMPONENT_MSG: &str = "symlinked path component in .magi discovery";
const TMP_DIR_PREFIX: &str = ".magi.tmp.";
#[cfg(unix)]
const RESTRICTIVE_DIR_MODE: u32 = 0o700;
#[cfg(unix)]
const RESTRICTIVE_FILE_MODE: u32 = 0o600;
#[cfg(windows)]
const WINDOWS_FULL_CONTROL_MASK: u32 = 0x1000_0000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Workspace {
pub root: PathBuf,
pub magi_dir: PathBuf,
}
impl Workspace {
#[must_use]
pub fn db_path(&self) -> PathBuf {
self.magi_dir.join(DB_FILE_NAME)
}
#[allow(dead_code)]
#[must_use]
pub fn config_path(&self) -> PathBuf {
self.magi_dir.join(CONFIG_FILE_NAME)
}
#[allow(dead_code)]
#[must_use]
pub fn logs_dir(&self) -> PathBuf {
self.magi_dir.join(LOGS_DIR_NAME)
}
}
pub fn discover(start: &Path) -> Result<Option<Workspace>, HeadlessError> {
let absolute = std::path::absolute(start).map_err(|e| HeadlessError::Io(e.to_string()))?;
ensure_raw_chain_symlink_free(&absolute)?;
let start_norm = lexical_normalize(&absolute);
for dir in collect_search_dirs(&start_norm)? {
let candidate = dir.join(MAGI_DIR_NAME);
match classify_magi_candidate(&candidate)? {
MagiCandidate::Directory => {
return Ok(Some(Workspace {
root: dir,
magi_dir: candidate,
}));
}
MagiCandidate::Absent => {}
}
}
Ok(None)
}
#[must_use]
pub fn detect_legacy_files(cwd: &Path) -> bool {
!cwd.join(MAGI_DIR_NAME).is_dir()
&& (cwd.join(DB_FILE_NAME).exists() || cwd.join(CONFIG_FILE_NAME).exists())
}
enum MagiCandidate {
Directory,
Absent,
}
fn classify_magi_candidate(candidate: &Path) -> Result<MagiCandidate, HeadlessError> {
match fs::symlink_metadata(candidate) {
Ok(md) => {
let file_type = md.file_type();
if file_type.is_symlink() {
Err(HeadlessError::InputInvalid(
SYMLINK_COMPONENT_MSG.to_owned(),
))
} else if file_type.is_dir() {
Ok(MagiCandidate::Directory)
} else {
Ok(MagiCandidate::Absent)
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(MagiCandidate::Absent),
Err(e) => Err(HeadlessError::Io(e.to_string())),
}
}
fn ensure_raw_chain_symlink_free(absolute: &Path) -> Result<(), HeadlessError> {
let mut prefix = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
prefix.pop();
}
Component::Prefix(_) | Component::RootDir => {
prefix.push(component.as_os_str());
}
Component::Normal(name) => {
prefix.push(name);
match fs::symlink_metadata(&prefix) {
Ok(md) if md.file_type().is_symlink() => {
return Err(HeadlessError::InputInvalid(
SYMLINK_COMPONENT_MSG.to_owned(),
));
}
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(HeadlessError::Io(e.to_string())),
}
}
}
}
Ok(())
}
fn collect_search_dirs(start: &Path) -> Result<Vec<PathBuf>, HeadlessError> {
let mut dirs = Vec::new();
let mut current = start.to_path_buf();
loop {
dirs.push(current.clone());
match current.parent() {
Some(parent) => {
let parent = parent.to_path_buf();
if is_fs_boundary(¤t, &parent)? {
break;
}
current = parent;
}
None => break,
}
}
Ok(dirs)
}
fn lexical_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(unix)]
fn is_fs_boundary(dir: &Path, parent: &Path) -> Result<bool, HeadlessError> {
use std::os::unix::fs::MetadataExt;
let dir_dev = fs::symlink_metadata(dir)
.map_err(|e| HeadlessError::Io(e.to_string()))?
.dev();
let parent_dev = fs::symlink_metadata(parent)
.map_err(|e| HeadlessError::Io(e.to_string()))?
.dev();
Ok(dir_dev != parent_dev)
}
#[cfg(windows)]
fn is_fs_boundary(dir: &Path, parent: &Path) -> Result<bool, HeadlessError> {
Ok(volume_prefix(dir) != volume_prefix(parent))
}
#[cfg(windows)]
fn volume_prefix(path: &Path) -> Option<std::ffi::OsString> {
path.components().find_map(|component| match component {
Component::Prefix(prefix) => Some(prefix.as_os_str().to_os_string()),
_ => None,
})
}
pub fn init(cwd: &Path) -> Result<Workspace, HeadlessError> {
let absolute = std::path::absolute(cwd).map_err(|e| HeadlessError::Io(e.to_string()))?;
ensure_raw_chain_symlink_free(&absolute)?;
let root = lexical_normalize(&absolute);
let magi_dir = root.join(MAGI_DIR_NAME);
place_magi_dir(&magi_dir)?;
Ok(Workspace { root, magi_dir })
}
fn place_magi_dir(magi_dir: &Path) -> Result<(), HeadlessError> {
let parent = magi_dir
.parent()
.ok_or_else(|| HeadlessError::Io("target .magi has no parent directory".to_owned()))?;
let tmp = parent.join(format!("{TMP_DIR_PREFIX}{:016x}", rand::random::<u64>()));
create_gate_dir(&tmp)?;
populate_or_cleanup(&tmp)?;
rename_no_replace(&tmp, magi_dir)
}
fn populate_or_cleanup(scaffold: &Path) -> Result<(), HeadlessError> {
populate_or_cleanup_with(scaffold, populate_in_place)
}
fn populate_or_cleanup_with<F>(scaffold: &Path, populate: F) -> Result<(), HeadlessError>
where
F: FnOnce(&Path) -> Result<(), HeadlessError>,
{
match populate(scaffold) {
Ok(()) => Ok(()),
Err(e) => {
let _ = fs::remove_dir_all(scaffold);
Err(e)
}
}
}
#[cfg(target_os = "linux")]
fn rename_no_replace(tmp: &Path, final_dir: &Path) -> Result<(), HeadlessError> {
use rustix::fs::{renameat_with, RenameFlags, CWD};
use rustix::io::Errno;
match renameat_with(CWD, tmp, CWD, final_dir, RenameFlags::NOREPLACE) {
Ok(()) => Ok(()),
Err(errno) => {
let _ = fs::remove_dir_all(tmp);
if errno == Errno::EXIST {
Err(HeadlessError::Aborted)
} else if errno == Errno::NOSYS || errno == Errno::INVAL || errno == Errno::OPNOTSUPP {
Err(HeadlessError::Io(format!(
"atomic no-replace directory creation is unsupported by this \
kernel/filesystem (renameat2 RENAME_NOREPLACE: {errno:?}); \
refusing to create .magi/ non-atomically (REQ-H07)"
)))
} else {
Err(HeadlessError::Io(format!("rename failed: {errno:?}")))
}
}
}
}
#[cfg(not(target_os = "linux"))]
fn rename_no_replace(tmp: &Path, final_dir: &Path) -> Result<(), HeadlessError> {
match fs::rename(tmp, final_dir) {
Ok(()) => Ok(()),
Err(e) => {
let _ = fs::remove_dir_all(tmp);
if matches!(
e.kind(),
io::ErrorKind::AlreadyExists | io::ErrorKind::DirectoryNotEmpty
) {
Err(HeadlessError::Aborted)
} else {
Err(HeadlessError::Io(e.to_string()))
}
}
}
}
fn create_gate_dir(path: &Path) -> Result<(), HeadlessError> {
create_restricted_dir_impl(path).map_err(map_create_err)?;
#[cfg(windows)]
restrict_to_current_user(path)?;
Ok(())
}
fn map_create_err(e: io::Error) -> HeadlessError {
if e.kind() == io::ErrorKind::AlreadyExists {
HeadlessError::Aborted
} else {
HeadlessError::Io(e.to_string())
}
}
#[cfg(unix)]
fn create_restricted_dir_impl(path: &Path) -> io::Result<()> {
use std::os::unix::fs::DirBuilderExt;
fs::DirBuilder::new()
.mode(RESTRICTIVE_DIR_MODE)
.create(path)
}
#[cfg(not(unix))]
fn create_restricted_dir_impl(path: &Path) -> io::Result<()> {
fs::create_dir(path)
}
fn populate_in_place(dir: &Path) -> Result<(), HeadlessError> {
create_gate_dir(&dir.join(LOGS_DIR_NAME))?;
write_restricted_file(
&dir.join(CONFIG_FILE_NAME),
crate::defaults::render_default_magi_toml().as_bytes(),
)?;
create_db(&dir.join(DB_FILE_NAME))?;
Ok(())
}
fn write_restricted_file(path: &Path, contents: &[u8]) -> Result<(), HeadlessError> {
let mut file = open_new_restricted(path).map_err(map_create_err)?;
file.write_all(contents)
.map_err(|e| HeadlessError::Io(e.to_string()))?;
#[cfg(windows)]
restrict_to_current_user(path)?;
Ok(())
}
#[cfg(unix)]
fn open_new_restricted(path: &Path) -> io::Result<fs::File> {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(RESTRICTIVE_FILE_MODE)
.open(path)
}
#[cfg(not(unix))]
fn open_new_restricted(path: &Path) -> io::Result<fs::File> {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
}
fn create_db(path: &Path) -> Result<(), HeadlessError> {
open_new_restricted(path).map_err(map_create_err)?;
{
let conn =
rusqlite::Connection::open(path).map_err(|e| HeadlessError::Storage(e.to_string()))?;
crate::system::database::init_schema(&conn)
.map_err(|e| HeadlessError::Storage(e.to_string()))?;
}
#[cfg(windows)]
restrict_to_current_user(path)?;
Ok(())
}
#[cfg(windows)]
fn restrict_to_current_user(path: &Path) -> Result<(), HeadlessError> {
use windows_acl::acl::{AceType, ACL};
use windows_acl::helper::{current_user, name_to_sid, sid_to_string, string_to_sid};
let path_str = path.to_str().ok_or_else(|| {
HeadlessError::Io("path is not valid UTF-8 for ACL application".to_owned())
})?;
let user = current_user()
.ok_or_else(|| HeadlessError::Io("cannot resolve current Windows user".to_owned()))?;
let user_sid = name_to_sid(&user, None)
.map_err(|code| HeadlessError::Io(format!("name_to_sid failed (code {code})")))?;
let user_string = sid_to_string(user_sid.as_ptr() as _)
.map_err(|code| HeadlessError::Io(format!("sid_to_string failed (code {code})")))?;
let mut acl = ACL::from_file_path(path_str, false)
.map_err(|code| HeadlessError::Io(format!("read ACL failed (code {code})")))?;
acl.add_entry(
user_sid.as_ptr() as _,
AceType::AccessAllow,
0,
WINDOWS_FULL_CONTROL_MASK,
)
.map_err(|code| HeadlessError::Io(format!("grant user ACE failed (code {code})")))?;
let entries = acl
.all()
.map_err(|code| HeadlessError::Io(format!("enumerate ACL failed (code {code})")))?;
for entry in entries {
if entry.string_sid == user_string {
continue;
}
let sid = string_to_sid(&entry.string_sid)
.map_err(|code| HeadlessError::Io(format!("string_to_sid failed (code {code})")))?;
acl.remove(sid.as_ptr() as _, None, None)
.map_err(|code| HeadlessError::Io(format!("remove ACE failed (code {code})")))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
create_gate_dir, detect_legacy_files, discover, init, populate_or_cleanup_with, Workspace,
};
use magi_rs::headless::HeadlessError;
use std::path::PathBuf;
#[test]
fn test_discover_finds_nearest_ancestor_magi_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir_all(root.join("a/b/.magi")).unwrap();
let sub = root.join("a/b/c/d");
std::fs::create_dir_all(&sub).unwrap();
let ws = discover(&sub).unwrap().expect("found");
assert_eq!(ws.magi_dir, root.join("a/b/.magi"));
assert_eq!(ws.root, root.join("a/b"));
}
#[test]
#[cfg(unix)]
fn test_discover_rejects_symlinked_magi_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let real = root.join("elsewhere");
std::fs::create_dir_all(&real).unwrap();
std::os::unix::fs::symlink(&real, root.join(".magi")).unwrap();
assert!(matches!(
discover(&root),
Err(HeadlessError::InputInvalid(_))
));
}
#[test]
#[cfg(unix)]
fn test_discover_rejects_symlinked_ancestor_component() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let real = root.join("real");
std::fs::create_dir_all(real.join(".magi")).unwrap();
let link = root.join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let sub = link.join("sub");
std::fs::create_dir_all(&sub).unwrap();
assert!(matches!(
discover(&sub),
Err(HeadlessError::InputInvalid(_))
));
}
#[test]
fn test_discover_returns_none_when_no_magi_dir_in_tree() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let sub = root.join("x/y/z");
std::fs::create_dir_all(&sub).unwrap();
assert_eq!(discover(&sub).unwrap(), None);
}
#[test]
fn test_workspace_path_helpers_are_under_magi_dir() {
let magi_dir = PathBuf::from("/proj/.magi");
let ws = Workspace {
root: PathBuf::from("/proj"),
magi_dir: magi_dir.clone(),
};
assert_eq!(ws.db_path(), magi_dir.join(".magi-rs-memory.db"));
assert_eq!(ws.config_path(), magi_dir.join("magi.toml"));
assert_eq!(ws.logs_dir(), magi_dir.join("logs"));
}
#[test]
fn test_init_creates_structure_and_refuses_overwrite() {
let tmp = tempfile::tempdir().unwrap();
let ws = init(tmp.path()).expect("init");
assert!(ws.magi_dir.join("magi.toml").exists());
assert!(ws.magi_dir.join("logs").is_dir());
assert!(ws.db_path().exists());
assert!(matches!(init(tmp.path()), Err(HeadlessError::Aborted)));
}
#[test]
#[cfg(unix)]
fn test_init_sets_restrictive_permissions() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let ws = init(tmp.path()).unwrap();
let dir_mode = std::fs::metadata(&ws.magi_dir)
.unwrap()
.permissions()
.mode()
& 0o777;
let db_mode = std::fs::metadata(ws.db_path())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700);
assert_eq!(db_mode, 0o600);
}
#[test]
#[cfg(windows)]
fn test_init_restricts_acl_to_current_user() {
use windows_acl::acl::ACL;
use windows_acl::helper::{current_user, name_to_sid, sid_to_string};
let tmp = tempfile::tempdir().unwrap();
let ws = init(tmp.path()).unwrap();
let user = current_user().unwrap();
let user_sid = name_to_sid(&user, None).unwrap();
let user_string = sid_to_string(user_sid.as_ptr() as _).unwrap();
let acl = ACL::from_file_path(ws.magi_dir.to_str().unwrap(), false).unwrap();
let entries = acl.all().unwrap();
assert!(!entries.is_empty(), "the DACL must contain the user's ACE");
for entry in entries {
assert_eq!(
entry.string_sid, user_string,
"only the current user may hold an ACE on .magi/"
);
}
assert!(ws.db_path().exists());
}
#[test]
fn test_init_concurrent_yields_exactly_one_magi_dir() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let handles: Vec<_> = (0..2)
.map(|_| {
let dir = dir.clone();
let barrier = std::sync::Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
init(&dir)
})
})
.collect();
let results: Vec<Result<Workspace, HeadlessError>> =
handles.into_iter().map(|h| h.join().unwrap()).collect();
let winners = results.iter().filter(|r| r.is_ok()).count();
let aborted = results
.iter()
.filter(|r| matches!(r, Err(HeadlessError::Aborted)))
.count();
assert_eq!(winners, 1, "exactly one concurrent init must win");
assert_eq!(
aborted, 1,
"the loser must be refused with Aborted (atomic no-replace)"
);
let magi = dir.join(".magi");
assert!(magi.join("magi.toml").exists(), "config present");
assert!(magi.join(".magi-rs-memory.db").exists(), "DB present");
assert!(magi.join("logs").is_dir(), "logs/ present");
}
#[test]
fn test_init_never_exposes_a_partially_populated_final_dir() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let magi = dir.join(".magi");
let seen_partial = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let watcher = {
let magi = magi.clone();
let seen_partial = std::sync::Arc::clone(&seen_partial);
let barrier = std::sync::Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
for _ in 0..2_000_000 {
if magi.is_dir() {
let complete = magi.join("magi.toml").exists()
&& magi.join("logs").is_dir()
&& magi.join(".magi-rs-memory.db").exists();
if !complete {
seen_partial.store(true, std::sync::atomic::Ordering::SeqCst);
}
break;
}
}
})
};
barrier.wait();
let ws = init(&dir).expect("init");
watcher.join().unwrap();
assert!(
!seen_partial.load(std::sync::atomic::Ordering::SeqCst),
"a concurrent observer must never see .magi/ half-populated"
);
assert!(ws.db_path().exists());
}
#[test]
fn test_orphan_tmp_dir_does_not_break_a_later_init() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".magi.tmp.deadbeef")).unwrap();
let ws = init(tmp.path()).expect("init succeeds despite the orphan tmp");
assert!(ws.magi_dir.is_dir());
assert!(ws.db_path().exists());
assert!(ws.magi_dir.join("magi.toml").exists());
}
#[test]
fn test_populate_failure_cleans_up_scaffold_and_allows_retry() {
let tmp = tempfile::tempdir().unwrap();
let scaffold = tmp.path().join(".magi");
create_gate_dir(&scaffold).expect("gate dir created");
assert!(
scaffold.is_dir(),
"the scaffold exists before populate runs"
);
let err = populate_or_cleanup_with(&scaffold, |_| {
Err(HeadlessError::Storage(
"injected populate failure".to_owned(),
))
})
.expect_err("the injected populate failure must propagate");
assert!(
matches!(err, HeadlessError::Storage(_)),
"the ORIGINAL populate error is returned, not the cleanup outcome"
);
assert!(
!scaffold.exists(),
"the half-built scaffold must be removed on populate failure"
);
let ws = init(tmp.path()).expect("init proceeds after the cleaned-up failure");
assert!(ws.db_path().exists());
assert!(ws.magi_dir.join("magi.toml").exists());
}
#[test]
fn test_fresh_init_db_bootstraps_cleanly_under_state_machine() {
let tmp = tempfile::tempdir().unwrap();
let ws = init(tmp.path()).unwrap();
let conn = rusqlite::Connection::open(ws.db_path()).unwrap();
for table in [
"sessions",
"messages",
"knowledge",
"memories",
"vault_meta",
] {
let count: i64 = conn
.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.unwrap_or_else(|_| panic!("table `{table}` must exist on fresh init"));
assert_eq!(count, 0, "table `{table}` must be empty on fresh init");
}
let has_envelope: i64 = conn
.query_row(
"SELECT COUNT(*) FROM vault_meta WHERE key = 'wrapped_dek'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(has_envelope, 0, "a fresh init has no envelope yet");
drop(conn);
match crate::system::database::EncryptedSqliteMemory::open_with_state_machine(
ws.db_path(),
zeroize::Zeroizing::new("fresh-init-state-machine-master".to_string()),
) {
Ok(_) => {}
Err(e) => panic!("fresh init must bootstrap cleanly under the state machine: {e:?}"),
}
}
#[test]
fn test_detect_legacy_files_true_for_loose_db_without_magi_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".magi-rs-memory.db"), b"x").unwrap();
assert!(detect_legacy_files(tmp.path()));
}
#[test]
fn test_detect_legacy_files_true_for_loose_config_without_magi_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("magi.toml"), b"x").unwrap();
assert!(detect_legacy_files(tmp.path()));
}
#[test]
fn test_detect_legacy_files_false_when_magi_dir_present() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".magi")).unwrap();
std::fs::write(tmp.path().join(".magi-rs-memory.db"), b"x").unwrap();
assert!(!detect_legacy_files(tmp.path()));
}
#[test]
fn test_detect_legacy_files_false_when_directory_is_clean() {
let tmp = tempfile::tempdir().unwrap();
assert!(!detect_legacy_files(tmp.path()));
}
#[test]
#[cfg(unix)]
fn test_discover_rejects_parentdir_through_symlink_component() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let real = root.join("real");
std::fs::create_dir_all(real.join(".magi")).unwrap();
std::fs::create_dir_all(real.join("sub")).unwrap();
std::os::unix::fs::symlink(&real, root.join("link")).unwrap();
let start = root.join("link").join("..").join("real").join("sub");
assert!(
matches!(discover(&start), Err(HeadlessError::InputInvalid(_))),
"a `..` that first traverses a symlinked component must be rejected"
);
}
#[test]
#[cfg(unix)]
fn test_init_rejects_symlinked_ancestor_component() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let real = root.join("real");
std::fs::create_dir_all(&real).unwrap();
let link = root.join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(
matches!(init(&link), Err(HeadlessError::InputInvalid(_))),
"init through a symlinked ancestor component must be rejected"
);
assert!(
!real.join(".magi").exists(),
"no .magi/ must be created through the rejected symlink"
);
}
#[test]
fn test_discover_allows_parentdir_on_non_symlinked_path() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir_all(root.join("a").join(".magi")).unwrap();
std::fs::create_dir_all(root.join("a").join("b")).unwrap();
std::fs::create_dir_all(root.join("a").join("c")).unwrap();
let start = root.join("a").join("b").join("..").join("c");
let ws = discover(&start).unwrap().expect("found");
assert_eq!(ws.magi_dir, root.join("a").join(".magi"));
assert_eq!(ws.root, root.join("a"));
}
}