mod gitignore;
mod handoff;
mod pointer;
mod write_atomic;
pub use handoff::{render_handoff, Distilled, MAX_HANDOFF_LINES};
pub use pointer::{
ensure_pointer_block, ensure_pointer_block_relative, pointer_block, remove_pointer_block,
POINTER_END, POINTER_START,
};
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PublishError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("project directory does not exist: {0}")]
ProjectDirMissing(PathBuf),
#[error("path traversal attempt: {0}")]
PathTraversal(PathBuf),
}
#[derive(Clone, Debug)]
pub struct PublishContext {
pub home_dir: PathBuf,
pub project_dir: PathBuf,
pub resume_mode: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishOutcome {
pub user_handoff: PathBuf,
pub project_handoff: PathBuf,
pub gitignore_modified: bool,
pub agents_md_modified: bool,
pub claude_md_modified: bool,
}
pub fn publish(
distilled: &Distilled,
ctx: &PublishContext,
) -> Result<PublishOutcome, PublishError> {
if path_has_parent_component(&ctx.project_dir) {
return Err(PublishError::PathTraversal(ctx.project_dir.clone()));
}
if !ctx.project_dir.exists() {
return Err(PublishError::ProjectDirMissing(ctx.project_dir.clone()));
}
let canonical_project = ctx
.project_dir
.canonicalize()
.map_err(|_| PublishError::ProjectDirMissing(ctx.project_dir.clone()))?;
if path_has_parent_component(&canonical_project) {
return Err(PublishError::PathTraversal(ctx.project_dir.clone()));
}
let body = handoff::render_handoff(distilled, &ctx.resume_mode);
let gitignore_modified = gitignore::ensure_carryover_ignored(&canonical_project)?;
let user_carryover_dir = ctx.home_dir.join(".carryover");
let user_handoff = user_carryover_dir.join("handoff.md");
let project_carryover_dir = canonical_project.join(".carryover");
let project_handoff = project_carryover_dir.join("handoff.md");
create_owner_only_dir(&user_carryover_dir)?;
create_owner_only_dir(&project_carryover_dir)?;
write_atomic::write_owner_only(&user_handoff, body.as_bytes())?;
write_atomic::write_owner_only(&project_handoff, body.as_bytes())?;
if !distilled.progress_log.is_empty() {
let progress_path = project_carryover_dir.join("progress.md");
write_atomic::write_owner_only(&progress_path, distilled.progress_log.as_bytes())?;
}
let user_bytes = std::fs::read(&user_handoff)?;
let project_bytes = std::fs::read(&project_handoff)?;
assert_eq!(user_bytes, project_bytes, "dual-write divergence");
let canonical_home = ctx
.home_dir
.canonicalize()
.unwrap_or_else(|_| ctx.home_dir.clone());
let is_project_level = canonical_project != canonical_home;
let (_agents_md, _claude_md, agents_md_modified, claude_md_modified) = if is_project_level {
let agents = canonical_project.join("AGENTS.md");
let claude = canonical_project.join("CLAUDE.md");
let a = pointer::ensure_pointer_block_relative(&agents)?;
let c = pointer::ensure_pointer_block_relative(&claude)?;
let _ = pointer::ensure_pointer_block(&ctx.home_dir.join("AGENTS.md"));
let _ = pointer::ensure_pointer_block(&ctx.home_dir.join("CLAUDE.md"));
(agents, claude, a, c)
} else {
let agents = canonical_project.join("AGENTS.md");
let claude = canonical_project.join("CLAUDE.md");
let a = pointer::ensure_pointer_block(&agents)?;
let c = pointer::ensure_pointer_block(&claude)?;
(agents, claude, a, c)
};
Ok(PublishOutcome {
user_handoff,
project_handoff,
gitignore_modified,
agents_md_modified,
claude_md_modified,
})
}
#[cfg(unix)]
fn create_owner_only_dir(p: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(p)
}
#[cfg(not(unix))]
fn create_owner_only_dir(p: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(p)
}
fn path_has_parent_component(p: &Path) -> bool {
p.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn sample_distilled() -> Distilled {
Distilled {
source_tool: "claude".to_string(),
session_id: "sess-1".to_string(),
timestamp_iso: "2026-04-28T00:00:00Z".to_string(),
task: "Build the publisher".to_string(),
open_questions: vec!["What about windows?".to_string()],
next_action: "Write tests".to_string(),
recent_files: vec!["src/publish/mod.rs".to_string()],
failed_approaches: vec![],
git_context: "branch publisher / clean".to_string(),
progress_log: String::new(),
}
}
fn ctx_in(dir: &Path) -> PublishContext {
PublishContext {
home_dir: dir.to_path_buf(),
project_dir: dir.to_path_buf(),
resume_mode: "ask".to_string(),
}
}
#[test]
fn publish_writes_both_handoffs_byte_identical() {
let dir = tempfile::tempdir().unwrap();
let outcome = publish(&sample_distilled(), &ctx_in(dir.path())).unwrap();
let user_bytes = fs::read(&outcome.user_handoff).unwrap();
let project_bytes = fs::read(&outcome.project_handoff).unwrap();
assert_eq!(user_bytes, project_bytes);
}
#[test]
fn publish_appends_carryover_to_gitignore() {
let dir = tempfile::tempdir().unwrap();
publish(&sample_distilled(), &ctx_in(dir.path())).unwrap();
let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(gi.contains(".carryover/"));
}
#[test]
fn publish_stamps_pointer_in_both_md_files() {
let dir = tempfile::tempdir().unwrap();
publish(&sample_distilled(), &ctx_in(dir.path())).unwrap();
let agents = fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
let claude = fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(agents.contains(POINTER_START));
assert!(claude.contains(POINTER_START));
}
#[test]
fn publish_idempotent_second_call_no_changes_to_md() {
let dir = tempfile::tempdir().unwrap();
publish(&sample_distilled(), &ctx_in(dir.path())).unwrap();
let second = publish(&sample_distilled(), &ctx_in(dir.path())).unwrap();
assert!(!second.agents_md_modified);
assert!(!second.claude_md_modified);
assert!(!second.gitignore_modified);
}
#[cfg(unix)]
#[test]
fn publish_creates_carryover_dirs_with_0700_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
publish(&sample_distilled(), &ctx_in(dir.path())).unwrap();
let mode = fs::metadata(dir.path().join(".carryover"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o700);
}
#[cfg(unix)]
#[test]
fn publish_handoff_files_have_0600_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let outcome = publish(&sample_distilled(), &ctx_in(dir.path())).unwrap();
for f in [&outcome.user_handoff, &outcome.project_handoff] {
let mode = fs::metadata(f).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "expected 0o600 on {f:?}");
}
}
#[test]
fn publish_rejects_path_traversal_in_project_dir() {
let dir = tempfile::tempdir().unwrap();
let bad = dir.path().join("..").join("evil");
let ctx = PublishContext {
home_dir: dir.path().to_path_buf(),
project_dir: bad,
resume_mode: "ask".to_string(),
};
let err = publish(&sample_distilled(), &ctx).unwrap_err();
assert!(
matches!(
err,
PublishError::PathTraversal(_) | PublishError::ProjectDirMissing(_)
),
"expected PathTraversal or ProjectDirMissing, got {err:?}"
);
}
#[test]
fn publish_rejects_nonexistent_project_dir() {
let dir = tempfile::tempdir().unwrap();
let ctx = PublishContext {
home_dir: dir.path().to_path_buf(),
project_dir: dir.path().join("does-not-exist"),
resume_mode: "ask".to_string(),
};
let err = publish(&sample_distilled(), &ctx).unwrap_err();
assert!(matches!(err, PublishError::ProjectDirMissing(_)));
}
#[test]
fn gitignore_is_written_before_handoff_directory_exists() {
let dir = tempfile::tempdir().unwrap();
publish(&sample_distilled(), &ctx_in(dir.path())).unwrap();
let gitignore = dir.path().join(".gitignore");
let project_handoff = dir.path().join(".carryover").join("handoff.md");
assert!(gitignore.exists());
assert!(project_handoff.exists());
let gi = fs::read_to_string(&gitignore).unwrap();
assert!(gi.contains(".carryover/"));
}
}