use std::{
collections::HashSet,
fmt, fs, io,
path::{Path, PathBuf},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use thiserror::Error;
use uuid::Uuid;
pub const OPENHANDS_CONVERSATIONS_PATH_ENV: &str = "OH_CONVERSATIONS_PATH";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConversationStoreKind {
Active,
Archived,
Legacy,
}
impl ConversationStoreKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Archived => "archived",
Self::Legacy => "legacy",
}
}
}
impl fmt::Display for ConversationStoreKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocatedConversation {
pub kind: ConversationStoreKind,
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocatedWorkspaceConversation {
pub conversation_id: String,
pub working_dir: PathBuf,
pub kind: ConversationStoreKind,
pub path: PathBuf,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConversationStoreScanReport {
pub conversations: Vec<LocatedWorkspaceConversation>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConversationMoveOutcome {
Moved {
from: ConversationStoreKind,
from_path: PathBuf,
to: ConversationStoreKind,
to_path: PathBuf,
},
AlreadyInTarget {
kind: ConversationStoreKind,
path: PathBuf,
},
Missing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenHandsConversationStorePaths {
pub repo_key: String,
pub legacy_root: PathBuf,
pub repo_root: PathBuf,
pub active: PathBuf,
pub archived: PathBuf,
}
impl OpenHandsConversationStorePaths {
pub fn for_tool_dir(
tool_dir: impl AsRef<Path>,
target_repo: impl AsRef<Path>,
) -> Result<Self, ConversationStoreError> {
let target_repo = canonicalize_repo_path(target_repo.as_ref())?;
let repo_key = repo_store_key(&target_repo);
let legacy_root = tool_dir.as_ref().join("workspace").join("conversations");
let repo_root = legacy_root.join("repos").join(&repo_key);
Ok(Self {
repo_key,
active: repo_root.join("active"),
archived: repo_root.join("archived"),
repo_root,
legacy_root,
})
}
pub fn ensure_active_and_archived(&self) -> Result<(), ConversationStoreError> {
self.create_store_dir(&self.active)?;
self.create_store_dir(&self.archived)
}
pub fn path_for(&self, kind: ConversationStoreKind) -> &Path {
match kind {
ConversationStoreKind::Active => &self.active,
ConversationStoreKind::Archived => &self.archived,
ConversationStoreKind::Legacy => &self.legacy_root,
}
}
pub fn locate_conversation(
&self,
conversation_id: &str,
) -> Result<Option<LocatedConversation>, ConversationStoreError> {
let names = conversation_dir_names(conversation_id)?;
for kind in [
ConversationStoreKind::Active,
ConversationStoreKind::Archived,
ConversationStoreKind::Legacy,
] {
for name in &names {
let path = self.path_for(kind).join(name);
if path.is_dir() {
return Ok(Some(LocatedConversation { kind, path }));
}
}
}
Ok(None)
}
pub fn move_conversation_to(
&self,
conversation_id: &str,
target: ConversationStoreKind,
) -> Result<ConversationMoveOutcome, ConversationStoreError> {
if target == ConversationStoreKind::Legacy {
return Err(ConversationStoreError::InvalidTarget { target });
}
self.ensure_active_and_archived()?;
let Some(located) = self.locate_conversation(conversation_id)? else {
return Ok(ConversationMoveOutcome::Missing);
};
if located.kind == target {
self.remove_duplicate_conversations(conversation_id, target, &located.path)?;
return Ok(ConversationMoveOutcome::AlreadyInTarget {
kind: located.kind,
path: located.path,
});
}
let destination = self
.path_for(target)
.join(conversation_dir_name(conversation_id)?);
if destination.exists() {
if destination.is_dir() {
remove_conversation_dir(&located.path)?;
self.remove_duplicate_conversations(conversation_id, target, &destination)?;
return Ok(ConversationMoveOutcome::AlreadyInTarget {
kind: target,
path: destination,
});
}
return Err(ConversationStoreError::DestinationExists { destination });
}
move_conversation_dir(&located.path, &destination)?;
Ok(ConversationMoveOutcome::Moved {
from: located.kind,
from_path: located.path,
to: target,
to_path: destination,
})
}
pub fn find_conversations_by_workspace_issue(
&self,
issue_key: &str,
) -> ConversationStoreScanReport {
let mut report = ConversationStoreScanReport::default();
let mut seen = HashSet::new();
for kind in [
ConversationStoreKind::Active,
ConversationStoreKind::Archived,
ConversationStoreKind::Legacy,
] {
self.scan_store_for_workspace_issue(kind, issue_key, &mut seen, &mut report);
}
report
.conversations
.sort_by(|left, right| left.conversation_id.cmp(&right.conversation_id));
report
}
fn create_store_dir(&self, path: &Path) -> Result<(), ConversationStoreError> {
fs::create_dir_all(path).map_err(|source| ConversationStoreError::CreateDirectory {
path: path.to_path_buf(),
source,
})
}
fn remove_duplicate_conversations(
&self,
conversation_id: &str,
target: ConversationStoreKind,
target_path: &Path,
) -> Result<(), ConversationStoreError> {
let names = conversation_dir_names(conversation_id)?;
for kind in [
ConversationStoreKind::Active,
ConversationStoreKind::Archived,
ConversationStoreKind::Legacy,
] {
if kind == target {
continue;
}
for name in &names {
let path = self.path_for(kind).join(name);
if path == target_path {
continue;
}
if path.is_dir() {
remove_conversation_dir(&path)?;
}
}
}
Ok(())
}
fn scan_store_for_workspace_issue(
&self,
kind: ConversationStoreKind,
issue_key: &str,
seen: &mut HashSet<String>,
report: &mut ConversationStoreScanReport,
) {
let store_path = self.path_for(kind);
let entries = match fs::read_dir(store_path) {
Ok(entries) => entries,
Err(source) if source.kind() == io::ErrorKind::NotFound => return,
Err(source) => {
report.warnings.push(format!(
"failed to scan OpenHands {kind} conversation store {}: {source}",
store_path.display()
));
return;
}
};
let mut paths = Vec::new();
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(source) => {
report.warnings.push(format!(
"failed to read OpenHands {kind} conversation store entry {}: {source}",
store_path.display()
));
continue;
}
};
let path = entry.path();
if kind == ConversationStoreKind::Legacy
&& entry.file_name().to_string_lossy().as_ref() == "repos"
{
continue;
}
if path.is_dir() {
paths.push(path);
}
}
paths.sort();
for path in paths {
let Some(metadata) = self.read_workspace_conversation_metadata(&path, kind, report)
else {
continue;
};
if !workspace_matches_issue(&metadata.working_dir, issue_key) {
continue;
}
let key = normalize_conversation_id_for_dedupe(&metadata.conversation_id);
if !seen.insert(key) {
continue;
}
report.conversations.push(LocatedWorkspaceConversation {
conversation_id: metadata.conversation_id,
working_dir: metadata.working_dir,
kind,
path,
});
}
}
fn read_workspace_conversation_metadata(
&self,
path: &Path,
kind: ConversationStoreKind,
report: &mut ConversationStoreScanReport,
) -> Option<WorkspaceConversationMetadata> {
let metadata_path = path.join("meta.json");
let raw = match fs::read_to_string(&metadata_path) {
Ok(raw) => raw,
Err(source) if source.kind() == io::ErrorKind::NotFound => return None,
Err(source) => {
report.warnings.push(format!(
"failed to read OpenHands {kind} conversation metadata {}: {source}",
metadata_path.display()
));
return None;
}
};
let metadata = match serde_json::from_str::<OpenHandsConversationMetadata>(&raw) {
Ok(metadata) => metadata,
Err(source) => {
report.warnings.push(format!(
"failed to decode OpenHands {kind} conversation metadata {}: {source}",
metadata_path.display()
));
return None;
}
};
let working_dir = metadata
.workspace
.and_then(|workspace| workspace.working_dir)?;
let conversation_id = metadata.conversation_id.or(metadata.id).or_else(|| {
path.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned)
})?;
Some(WorkspaceConversationMetadata {
conversation_id,
working_dir,
})
}
}
#[derive(Debug, Deserialize)]
struct OpenHandsConversationMetadata {
#[serde(default)]
conversation_id: Option<String>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
workspace: Option<OpenHandsWorkspaceMetadata>,
}
#[derive(Debug, Deserialize)]
struct OpenHandsWorkspaceMetadata {
#[serde(default)]
working_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct WorkspaceConversationMetadata {
conversation_id: String,
working_dir: PathBuf,
}
#[derive(Debug, Error)]
pub enum ConversationStoreError {
#[error("failed to resolve target repository path {path}: {source}")]
ResolveRepoPath {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("conversation id `{value}` is not a UUID: {source}")]
InvalidConversationId {
value: String,
#[source]
source: uuid::Error,
},
#[error("failed to create OpenHands conversation store {path}: {source}")]
CreateDirectory {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("cannot move conversations into the {target} OpenHands store")]
InvalidTarget { target: ConversationStoreKind },
#[error("OpenHands conversation archive destination already exists: {destination}")]
DestinationExists { destination: PathBuf },
#[error("failed to move OpenHands conversation from {from} to {to}: {source}")]
MoveConversation {
from: PathBuf,
to: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to copy OpenHands conversation from {from} to {to}: {source}")]
CopyConversation {
from: PathBuf,
to: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to remove OpenHands conversation {path}: {source}")]
RemoveConversation {
path: PathBuf,
#[source]
source: io::Error,
},
}
fn canonicalize_repo_path(path: &Path) -> Result<PathBuf, ConversationStoreError> {
fs::canonicalize(path).map_err(|source| ConversationStoreError::ResolveRepoPath {
path: path.to_path_buf(),
source,
})
}
fn repo_store_key(target_repo: &Path) -> String {
let slug_source = target_repo
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("repo");
let slug = sanitize_repo_slug(slug_source);
let digest = Sha256::digest(target_repo.to_string_lossy().as_bytes());
let hash = digest[..6]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!("{slug}-{hash}")
}
fn workspace_matches_issue(working_dir: &Path, issue_key: &str) -> bool {
working_dir
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(issue_key.trim()))
}
fn normalize_conversation_id_for_dedupe(conversation_id: &str) -> String {
conversation_id
.trim()
.chars()
.filter(|character| *character != '-')
.flat_map(char::to_lowercase)
.collect()
}
fn sanitize_repo_slug(value: &str) -> String {
let mut output = String::new();
let mut last_was_separator = false;
for character in value.chars() {
let next = if character.is_ascii_alphanumeric() {
last_was_separator = false;
Some(character.to_ascii_lowercase())
} else if matches!(character, '-' | '_' | '.') {
if last_was_separator {
None
} else {
last_was_separator = true;
Some('-')
}
} else if last_was_separator {
None
} else {
last_was_separator = true;
Some('-')
};
if let Some(next) = next
&& output.len() < 48
{
output.push(next);
}
}
let trimmed = output.trim_matches('-').to_string();
if trimmed.is_empty() {
"repo".to_string()
} else {
trimmed
}
}
fn conversation_dir_names(conversation_id: &str) -> Result<Vec<String>, ConversationStoreError> {
let compact = conversation_dir_name(conversation_id)?;
let raw = conversation_id.trim().to_string();
if raw == compact {
Ok(vec![compact])
} else {
Ok(vec![compact, raw])
}
}
fn conversation_dir_name(conversation_id: &str) -> Result<String, ConversationStoreError> {
Uuid::parse_str(conversation_id.trim())
.map(|uuid| uuid.simple().to_string())
.map_err(|source| ConversationStoreError::InvalidConversationId {
value: conversation_id.to_string(),
source,
})
}
fn move_conversation_dir(from: &Path, to: &Path) -> Result<(), ConversationStoreError> {
move_conversation_dir_with_ops(
from,
to,
|from, to| fs::rename(from, to),
copy_conversation_dir,
)
}
fn move_conversation_dir_with_ops<R, C>(
from: &Path,
to: &Path,
rename: R,
copy: C,
) -> Result<(), ConversationStoreError>
where
R: FnOnce(&Path, &Path) -> io::Result<()>,
C: FnOnce(&Path, &Path) -> Result<(), ConversationStoreError>,
{
match rename(from, to) {
Ok(()) => Ok(()),
Err(source) if source.kind() == io::ErrorKind::CrossesDevices => {
if let Err(error) = copy(from, to) {
let _ = fs::remove_dir_all(to);
return Err(error);
}
remove_conversation_dir(from)
}
Err(source) => Err(ConversationStoreError::MoveConversation {
from: from.to_path_buf(),
to: to.to_path_buf(),
source,
}),
}
}
fn remove_conversation_dir(path: &Path) -> Result<(), ConversationStoreError> {
fs::remove_dir_all(path).map_err(|source| ConversationStoreError::RemoveConversation {
path: path.to_path_buf(),
source,
})
}
fn copy_conversation_dir(from: &Path, to: &Path) -> Result<(), ConversationStoreError> {
fs::create_dir(to).map_err(|source| ConversationStoreError::CopyConversation {
from: from.to_path_buf(),
to: to.to_path_buf(),
source,
})?;
for entry in fs::read_dir(from).map_err(|source| ConversationStoreError::CopyConversation {
from: from.to_path_buf(),
to: to.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| ConversationStoreError::CopyConversation {
from: from.to_path_buf(),
to: to.to_path_buf(),
source,
})?;
let source_path = entry.path();
let destination_path = to.join(entry.file_name());
let file_type =
entry
.file_type()
.map_err(|source| ConversationStoreError::CopyConversation {
from: source_path.clone(),
to: destination_path.clone(),
source,
})?;
if file_type.is_dir() {
copy_conversation_dir(&source_path, &destination_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &destination_path).map_err(|source| {
ConversationStoreError::CopyConversation {
from: source_path.clone(),
to: destination_path.clone(),
source,
}
})?;
} else if file_type.is_symlink() {
copy_symlink(&source_path, &destination_path).map_err(|source| {
ConversationStoreError::CopyConversation {
from: source_path.clone(),
to: destination_path.clone(),
source,
}
})?;
} else {
return Err(ConversationStoreError::CopyConversation {
from: source_path.clone(),
to: destination_path.clone(),
source: io::Error::other("unsupported file type in conversation directory"),
});
}
}
Ok(())
}
#[cfg(unix)]
fn copy_symlink(from: &Path, to: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(fs::read_link(from)?, to)
}
#[cfg(not(unix))]
fn copy_symlink(_from: &Path, _to: &Path) -> io::Result<()> {
Err(io::Error::other(
"copying symlinks is unsupported on this platform",
))
}
#[cfg(test)]
mod tests {
use super::{ConversationMoveOutcome, ConversationStoreKind, OpenHandsConversationStorePaths};
#[test]
fn store_paths_are_repo_scoped_under_managed_conversations() {
let tool_dir = tempfile::tempdir().expect("tool dir");
let repo = tempfile::tempdir().expect("repo");
let paths = OpenHandsConversationStorePaths::for_tool_dir(tool_dir.path(), repo.path())
.expect("paths should resolve");
assert!(paths.legacy_root.ends_with("workspace/conversations"));
assert!(paths.repo_root.starts_with(&paths.legacy_root));
assert!(paths.repo_root.ends_with(&paths.repo_key));
assert_eq!(paths.active, paths.repo_root.join("active"));
assert_eq!(paths.archived, paths.repo_root.join("archived"));
}
#[test]
fn locate_checks_active_archived_then_legacy_compact_uuid_dirs() {
let tool_dir = tempfile::tempdir().expect("tool dir");
let repo = tempfile::tempdir().expect("repo");
let paths = OpenHandsConversationStorePaths::for_tool_dir(tool_dir.path(), repo.path())
.expect("paths should resolve");
let conversation_id = "dd258bb7-cc1b-415c-9892-e19af34a2e66";
let compact_id = "dd258bb7cc1b415c9892e19af34a2e66";
std::fs::create_dir_all(paths.archived.join(compact_id))
.expect("archived conversation should be created");
std::fs::create_dir_all(paths.legacy_root.join(compact_id))
.expect("legacy duplicate should be created");
let located = paths
.locate_conversation(conversation_id)
.expect("lookup should succeed")
.expect("conversation should be found");
assert_eq!(located.kind, ConversationStoreKind::Archived);
assert_eq!(located.path, paths.archived.join(compact_id));
}
#[test]
fn move_conversation_to_archive_moves_from_active_store() {
let tool_dir = tempfile::tempdir().expect("tool dir");
let repo = tempfile::tempdir().expect("repo");
let paths = OpenHandsConversationStorePaths::for_tool_dir(tool_dir.path(), repo.path())
.expect("paths should resolve");
let conversation_id = "dd258bb7-cc1b-415c-9892-e19af34a2e66";
let compact_id = "dd258bb7cc1b415c9892e19af34a2e66";
let active = paths.active.join(compact_id);
std::fs::create_dir_all(&active).expect("active conversation should be created");
let outcome = paths
.move_conversation_to(conversation_id, ConversationStoreKind::Archived)
.expect("move should succeed");
assert!(matches!(
outcome,
ConversationMoveOutcome::Moved {
from: ConversationStoreKind::Active,
to: ConversationStoreKind::Archived,
..
}
));
assert!(!active.exists());
assert!(paths.archived.join(compact_id).is_dir());
}
#[test]
fn move_conversation_to_archive_migrates_legacy_flat_store() {
let tool_dir = tempfile::tempdir().expect("tool dir");
let repo = tempfile::tempdir().expect("repo");
let paths = OpenHandsConversationStorePaths::for_tool_dir(tool_dir.path(), repo.path())
.expect("paths should resolve");
let conversation_id = "dd258bb7-cc1b-415c-9892-e19af34a2e66";
let compact_id = "dd258bb7cc1b415c9892e19af34a2e66";
let legacy = paths.legacy_root.join(compact_id);
std::fs::create_dir_all(&legacy).expect("legacy conversation should be created");
let outcome = paths
.move_conversation_to(conversation_id, ConversationStoreKind::Archived)
.expect("move should succeed");
assert!(matches!(
outcome,
ConversationMoveOutcome::Moved {
from: ConversationStoreKind::Legacy,
to: ConversationStoreKind::Archived,
..
}
));
assert!(!legacy.exists());
assert!(paths.archived.join(compact_id).is_dir());
}
#[test]
fn move_conversation_treats_existing_target_directory_as_idempotent() {
let tool_dir = tempfile::tempdir().expect("tool dir");
let repo = tempfile::tempdir().expect("repo");
let paths = OpenHandsConversationStorePaths::for_tool_dir(tool_dir.path(), repo.path())
.expect("paths should resolve");
let conversation_id = "dd258bb7-cc1b-415c-9892-e19af34a2e66";
let compact_id = "dd258bb7cc1b415c9892e19af34a2e66";
let active = paths.active.join(compact_id);
let legacy = paths.legacy_root.join(compact_id);
std::fs::create_dir_all(&active).expect("active conversation should be created");
std::fs::create_dir_all(&legacy).expect("legacy duplicate should be created");
let outcome = paths
.move_conversation_to(conversation_id, ConversationStoreKind::Active)
.expect("move should reconcile duplicate");
assert!(matches!(
outcome,
ConversationMoveOutcome::AlreadyInTarget {
kind: ConversationStoreKind::Active,
..
}
));
assert!(active.is_dir());
assert!(!legacy.exists());
}
#[test]
fn locate_supports_raw_uuid_directory_names() {
let tool_dir = tempfile::tempdir().expect("tool dir");
let repo = tempfile::tempdir().expect("repo");
let paths = OpenHandsConversationStorePaths::for_tool_dir(tool_dir.path(), repo.path())
.expect("paths should resolve");
let conversation_id = "dd258bb7-cc1b-415c-9892-e19af34a2e66";
let raw_path = paths.active.join(conversation_id);
std::fs::create_dir_all(&raw_path).expect("raw conversation should be created");
let located = paths
.locate_conversation(conversation_id)
.expect("lookup should succeed")
.expect("conversation should be found");
assert_eq!(located.kind, ConversationStoreKind::Active);
assert_eq!(located.path, raw_path);
}
#[test]
fn find_conversations_by_workspace_issue_reads_meta_from_scoped_and_legacy_stores() {
let tool_dir = tempfile::tempdir().expect("tool dir");
let repo = tempfile::tempdir().expect("repo");
let paths = OpenHandsConversationStorePaths::for_tool_dir(tool_dir.path(), repo.path())
.expect("paths should resolve");
let active_id = "dd258bb7cc1b415c9892e19af34a2e66";
let archived_id = "383db8232f644b7ba8aa7eecfdd656f5";
let legacy_id = "9bfe25c4b5ca4f62b172d15f5f672217";
let unrelated_id = "6b8ef416999f4e98bb0bad13fe858114";
write_meta(
&paths.active.join(active_id),
active_id,
"/tmp/workspaces/COE-337",
);
write_meta(
&paths.archived.join(archived_id),
archived_id,
"/tmp/workspaces/COE-337",
);
write_meta(
&paths.legacy_root.join(legacy_id),
legacy_id,
"/tmp/workspaces/COE-337",
);
write_meta(
&paths.active.join(unrelated_id),
unrelated_id,
"/tmp/workspaces/COE-338",
);
std::fs::create_dir_all(paths.legacy_root.join("repos").join("ignored"))
.expect("repo-scoped root should be created under legacy root");
let report = paths.find_conversations_by_workspace_issue("COE-337");
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
assert_eq!(report.conversations.len(), 3);
assert!(
report
.conversations
.iter()
.any(
|conversation| conversation.kind == ConversationStoreKind::Active
&& conversation.conversation_id == active_id
)
);
assert!(
report
.conversations
.iter()
.any(
|conversation| conversation.kind == ConversationStoreKind::Archived
&& conversation.conversation_id == archived_id
)
);
assert!(
report
.conversations
.iter()
.any(
|conversation| conversation.kind == ConversationStoreKind::Legacy
&& conversation.conversation_id == legacy_id
)
);
}
#[test]
fn copy_conversation_dir_copies_nested_files() {
let temp = tempfile::tempdir().expect("temp dir");
let source = temp.path().join("source");
let destination = temp.path().join("destination");
std::fs::create_dir_all(source.join("events")).expect("source should be created");
std::fs::write(source.join("meta.json"), "{}").expect("meta should write");
std::fs::write(source.join("events").join("1.json"), "{\"id\":\"1\"}")
.expect("event should write");
super::copy_conversation_dir(&source, &destination).expect("copy should succeed");
assert_eq!(
std::fs::read_to_string(destination.join("meta.json")).expect("meta should read"),
"{}"
);
assert_eq!(
std::fs::read_to_string(destination.join("events").join("1.json"))
.expect("event should read"),
"{\"id\":\"1\"}"
);
}
#[test]
fn move_conversation_dir_falls_back_to_copy_on_cross_device_rename() {
let temp = tempfile::tempdir().expect("temp dir");
let source = temp.path().join("source");
let destination = temp.path().join("destination");
std::fs::create_dir_all(source.join("events")).expect("source should be created");
std::fs::write(source.join("events").join("1.json"), "{\"id\":\"1\"}")
.expect("event should write");
super::move_conversation_dir_with_ops(
&source,
&destination,
|_from, _to| {
Err(std::io::Error::new(
std::io::ErrorKind::CrossesDevices,
"exdev",
))
},
super::copy_conversation_dir,
)
.expect("cross-device fallback should copy then remove source");
assert!(!source.exists());
assert_eq!(
std::fs::read_to_string(destination.join("events").join("1.json"))
.expect("event should read"),
"{\"id\":\"1\"}"
);
}
#[test]
fn move_conversation_dir_removes_partial_copy_after_cross_device_failure() {
let temp = tempfile::tempdir().expect("temp dir");
let source = temp.path().join("source");
let destination = temp.path().join("destination");
std::fs::create_dir_all(&source).expect("source should be created");
let error = super::move_conversation_dir_with_ops(
&source,
&destination,
|_from, _to| {
Err(std::io::Error::new(
std::io::ErrorKind::CrossesDevices,
"exdev",
))
},
|from, to| {
std::fs::create_dir_all(to).expect("partial destination should be created");
std::fs::write(to.join("partial"), "partial").expect("partial should write");
Err(super::ConversationStoreError::CopyConversation {
from: from.to_path_buf(),
to: to.to_path_buf(),
source: std::io::Error::other("copy failed"),
})
},
)
.expect_err("copy failure should be returned");
assert!(matches!(
error,
super::ConversationStoreError::CopyConversation { .. }
));
assert!(source.is_dir());
assert!(!destination.exists());
}
fn write_meta(path: &std::path::Path, conversation_id: &str, working_dir: &str) {
std::fs::create_dir_all(path).expect("conversation dir should be created");
std::fs::write(
path.join("meta.json"),
serde_json::json!({
"conversation_id": conversation_id,
"workspace": {
"working_dir": working_dir,
"kind": "LocalWorkspace"
}
})
.to_string(),
)
.expect("conversation metadata should be written");
}
}