use std::io;
use std::io::Write;
use std::path::{Path, PathBuf};
use runandlog_core::{
Canceller, Document, ExecOptions, ExecOutcome, RenderContext, Sidecar, render_result,
run_cancellable, splice,
};
pub struct Session {
path: PathBuf,
doc: Document,
exec: ExecOptions,
render: RenderContext,
}
impl Session {
pub fn load(path: &Path, exec: ExecOptions, max_inline_lines: usize) -> io::Result<Session> {
let path = path.canonicalize()?;
let text = std::fs::read_to_string(&path)?;
let md_dir = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let md_stem = path
.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
.unwrap_or_else(|| "runandlog".to_string());
Ok(Session {
doc: Document::parse(&text),
path,
exec,
render: RenderContext {
md_dir,
md_stem,
max_inline_lines,
},
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn doc(&self) -> &Document {
&self.doc
}
pub fn len(&self) -> usize {
self.doc.cells.len()
}
pub fn is_empty(&self) -> bool {
self.doc.cells.is_empty()
}
pub fn command_of(&self, index: usize) -> String {
self.doc.cells[index].command.clone()
}
pub fn exec_options(&self) -> ExecOptions {
self.exec.clone()
}
pub fn run_cell(&mut self, index: usize) -> io::Result<ExecOutcome> {
self.run_cell_cancellable(index, &Canceller::new())
}
pub fn run_cell_cancellable(
&mut self,
index: usize,
canceller: &Canceller,
) -> io::Result<ExecOutcome> {
let outcome = run_cancellable(
&self.doc.cells[index].command.clone(),
&self.exec,
canceller,
)?;
self.apply_outcome(index, &outcome)?;
Ok(outcome)
}
pub fn apply_outcome(&mut self, index: usize, outcome: &ExecOutcome) -> io::Result<()> {
self.refresh_before_write()?;
let cell = self.doc.cells[index].clone();
let out_file_allowed = match &cell.out_file {
Some(link) => {
let target = self.render.md_dir.join(link);
!target.is_dir() && is_safe_out_file(&self.render.md_dir, &self.path, &target)?
}
None => true,
};
let mut rendered = render_result(&cell, outcome, &self.render, out_file_allowed);
let sidecar_failed = match rendered.sidecar.as_ref() {
Some(sidecar) => write_sidecar(sidecar).is_err(),
None => false,
};
if sidecar_failed {
let inline = RenderContext {
max_inline_lines: usize::MAX,
..self.render.clone()
};
rendered = render_result(&cell, outcome, &inline, false);
debug_assert!(rendered.sidecar.is_none());
}
let updated = splice(
&self.doc.text,
vec![self.doc.result_edit(&cell, &rendered.markdown)],
);
write_atomically(&self.path, &updated)?;
self.doc = Document::parse(&updated);
Ok(())
}
fn refresh_before_write(&mut self) -> io::Result<()> {
let current = std::fs::read_to_string(&self.path)?;
if current == self.doc.text {
return Ok(());
}
let reparsed = Document::parse(¤t);
let commands = |doc: &Document| -> Vec<String> {
doc.cells.iter().map(|cell| cell.command.clone()).collect()
};
if commands(&reparsed) != commands(&self.doc) {
return Err(io::Error::other(
"the Markdown changed while the command was running, so the result could not be written back; reload and run again",
));
}
self.doc = reparsed;
Ok(())
}
pub fn reload(&mut self) -> io::Result<()> {
let text = std::fs::read_to_string(&self.path)?;
self.doc = Document::parse(&text);
Ok(())
}
}
fn write_sidecar(sidecar: &Sidecar) -> io::Result<()> {
if let Some(parent) = sidecar.path.parent() {
std::fs::create_dir_all(parent)?;
}
write_atomically(&sidecar.path, &sidecar.contents)
}
fn write_atomically(path: &Path, contents: &str) -> io::Result<()> {
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "runandlog".to_string());
let mut temp = PathBuf::new();
let mut file = None;
for attempt in 0..64 {
let candidate = path.with_file_name(format!(
".{file_name}.runandlog-{}-{attempt}.tmp",
std::process::id()
));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(opened) => {
temp = candidate;
file = Some(opened);
break;
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
let Some(file) = file else {
return Err(io::Error::other("could not create a temporary file"));
};
let result = finish_atomic_write(file, &temp, path, contents);
if result.is_err() {
let _ = std::fs::remove_file(&temp);
}
result
}
fn finish_atomic_write(
mut file: std::fs::File,
temp: &Path,
path: &Path,
contents: &str,
) -> io::Result<()> {
file.write_all(contents.as_bytes())?;
drop(file);
if let Ok(metadata) = std::fs::metadata(path) {
std::fs::set_permissions(temp, metadata.permissions())?;
}
std::fs::rename(temp, path)
}
fn resolve_as_far_as_possible(target: &Path) -> io::Result<Option<PathBuf>> {
let mut existing = target.to_path_buf();
let mut rest = Vec::new();
while !existing.exists() {
let Some(name) = existing.file_name().map(|name| name.to_owned()) else {
return Ok(None);
};
let Some(parent) = existing.parent().map(Path::to_path_buf) else {
return Ok(None);
};
rest.push(name);
existing = parent;
}
let mut resolved = existing.canonicalize()?;
for name in rest.iter().rev() {
resolved.push(name);
}
Ok(Some(resolved))
}
fn is_safe_out_file(base: &Path, markdown: &Path, target: &Path) -> io::Result<bool> {
let base = base.canonicalize()?;
let Some(resolved) = resolve_as_far_as_possible(target)? else {
return Ok(false);
};
Ok(resolved.starts_with(&base) && resolved != markdown)
}