use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::sources::SourceRecord;
use crate::{ScopeIdentity, WikiError};
pub const ACTION_SOURCE_INGESTED: &str = "source_ingested";
pub const ACTION_PAGE_CREATED: &str = "page_created";
pub const ACTION_PAGE_UPDATED: &str = "page_updated";
pub const ACTION_UPKEEP_COMPLETED: &str = "upkeep_completed";
pub const ACTION_RECAP_COMPLETED: &str = "recap_completed";
pub const ACTION_LIFECYCLE_TRANSITION: &str = "lifecycle_transition";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogEntry {
pub timestamp: String,
pub scope: ScopeIdentity,
pub action: String,
pub summary: String,
pub artifacts: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code, reason = "reserved gwiki CLI/API split")]
pub struct LogWriteReport {
pub scope_log: PathBuf,
pub global_log: Option<PathBuf>,
}
pub fn source_ingested_entry(
timestamp: &str,
scope: &ScopeIdentity,
record: &SourceRecord,
raw_path: &Path,
) -> LogEntry {
LogEntry {
timestamp: timestamp.to_string(),
scope: scope.clone(),
action: ACTION_SOURCE_INGESTED.to_string(),
summary: format!("{} {}", record.id, record.location),
artifacts: vec![raw_path.to_path_buf()],
}
}
pub fn append_sources_ingested<'a>(
vault_root: &Path,
scope: &ScopeIdentity,
timestamp: &str,
results: impl IntoIterator<Item = &'a crate::ingest::IngestResult>,
) -> Result<(), WikiError> {
let entries = results
.into_iter()
.map(|result| source_ingested_entry(timestamp, scope, &result.record, &result.raw_path))
.collect::<Vec<_>>();
append_log_entries(vault_root, None, &entries)?;
Ok(())
}
pub fn append_logs(
scope_root: &Path,
global_hub_root: Option<&Path>,
entry: &LogEntry,
) -> Result<LogWriteReport, WikiError> {
let scope_log = scope_root.join("log.md");
append_log(&scope_log, entry)?;
let global_log = global_hub_root
.map(|root| root.join("log.md"))
.map(|path| {
if !same_log_path(&scope_log, &path) {
append_log(&path, entry)?;
}
Ok::<PathBuf, WikiError>(path)
})
.transpose()?;
Ok(LogWriteReport {
scope_log,
global_log,
})
}
fn append_log_entries(
scope_root: &Path,
global_hub_root: Option<&Path>,
entries: &[LogEntry],
) -> Result<Option<LogWriteReport>, WikiError> {
if entries.is_empty() {
return Ok(None);
}
let scope_log = scope_root.join("log.md");
append_log_entries_to_file(&scope_log, entries)?;
let global_log = global_hub_root
.map(|root| root.join("log.md"))
.map(|path| {
if !same_log_path(&scope_log, &path) {
append_log_entries_to_file(&path, entries)?;
}
Ok::<PathBuf, WikiError>(path)
})
.transpose()?;
let report = LogWriteReport {
scope_log,
global_log,
};
Ok(Some(report))
}
#[allow(dead_code, reason = "reserved gwiki CLI/API split")]
fn append_log(path: &Path, entry: &LogEntry) -> Result<(), WikiError> {
append_log_entries_to_file(path, std::slice::from_ref(entry))
}
fn append_log_entries_to_file(path: &Path, entries: &[LogEntry]) -> Result<(), WikiError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| WikiError::Io {
action: "create log directory",
path: Some(parent.to_path_buf()),
source: error,
})?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|error| WikiError::Io {
action: "open log",
path: Some(path.to_path_buf()),
source: error,
})?;
let write_header = file
.metadata()
.map(|metadata| metadata.len() == 0)
.unwrap_or(false);
if write_header {
file.write_all(b"# Log\n\n")
.map_err(|error| WikiError::Io {
action: "write log",
path: Some(path.to_path_buf()),
source: error,
})?;
}
let rendered = entries.iter().map(render_entry).collect::<String>();
file.write_all(rendered.as_bytes())
.map_err(|error| WikiError::Io {
action: "write log",
path: Some(path.to_path_buf()),
source: error,
})
}
#[allow(dead_code, reason = "reserved gwiki CLI/API split")]
fn render_entry(entry: &LogEntry) -> String {
let mut rendered = format!(
"- {} [{}] {}: {}",
single_line(&entry.timestamp),
entry.scope,
single_line(&entry.action),
single_line(&entry.summary),
);
if !entry.artifacts.is_empty() {
let artifacts = entry
.artifacts
.iter()
.map(|artifact| artifact.display().to_string())
.collect::<Vec<_>>()
.join(", ");
rendered.push_str(" (artifacts: ");
rendered.push_str(&single_line(&artifacts));
rendered.push(')');
}
rendered.push('\n');
rendered
}
fn single_line(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[allow(dead_code, reason = "reserved gwiki CLI/API split")]
fn same_log_path(left: &Path, right: &Path) -> bool {
let resolved_left = resolved_log_path(left);
let resolved_right = resolved_log_path(right);
resolved_left == resolved_right || same_file_identity(&resolved_left, &resolved_right)
}
#[cfg(unix)]
#[allow(dead_code, reason = "reserved gwiki CLI/API split")]
fn same_file_identity(left: &Path, right: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let (Ok(left), Ok(right)) = (std::fs::metadata(left), std::fs::metadata(right)) else {
return false;
};
left.dev() == right.dev() && left.ino() == right.ino()
}
#[cfg(windows)]
#[allow(dead_code, reason = "reserved gwiki CLI/API split")]
fn same_file_identity(left: &Path, right: &Path) -> bool {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
};
fn identity(path: &Path) -> Option<(u32, u32, u32)> {
let file = std::fs::File::open(path).ok()?;
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let ok = unsafe { GetFileInformationByHandle(file.as_raw_handle() as HANDLE, &mut info) };
if ok == 0 {
return None;
}
if info.nFileIndexHigh == 0 && info.nFileIndexLow == 0 {
return None;
}
Some((
info.dwVolumeSerialNumber,
info.nFileIndexHigh,
info.nFileIndexLow,
))
}
match (identity(left), identity(right)) {
(Some(left), Some(right)) => left == right,
_ => false,
}
}
#[cfg(not(any(unix, windows)))]
fn same_file_identity(_left: &Path, _right: &Path) -> bool {
false
}
#[allow(dead_code, reason = "reserved gwiki CLI/API split")]
fn resolved_log_path(path: &Path) -> PathBuf {
if let Ok(resolved) = path.canonicalize() {
return resolved;
}
resolve_log_path_fallback(path)
}
#[allow(dead_code, reason = "reserved gwiki CLI/API split")]
fn resolve_log_path_fallback(path: &Path) -> PathBuf {
if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name())
&& let Ok(parent) = parent.canonicalize()
{
return parent.join(file_name);
}
path.to_path_buf()
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[test]
fn writes_scope_and_global_logs() {
let temp = tempfile::tempdir().expect("tempdir");
let scope_root = temp.path().join("scope");
let hub_root = temp.path().join("hub");
fs::create_dir_all(&scope_root).expect("scope root");
fs::create_dir_all(&hub_root).expect("hub root");
let entry = LogEntry {
timestamp: "2026-05-29T19:00:00Z".to_string(),
scope: ScopeIdentity::topic("rust"),
action: "query".to_string(),
summary: "Answered ownership question".to_string(),
artifacts: vec!["outputs/query-ownership.md".into()],
};
let report = append_logs(&scope_root, Some(&hub_root), &entry).expect("logs are appended");
assert_eq!(report.scope_log, scope_root.join("log.md"));
assert_eq!(report.global_log, Some(hub_root.join("log.md")));
let scope_log = fs::read_to_string(scope_root.join("log.md")).expect("scope log");
assert_eq!(
scope_log,
"# Log\n\n- 2026-05-29T19:00:00Z [topic:rust] query: Answered ownership question \
(artifacts: outputs/query-ownership.md)\n"
);
let global_log = fs::read_to_string(hub_root.join("log.md")).expect("global log");
assert_eq!(global_log, scope_log);
}
#[test]
fn renders_entries_as_single_greppable_lines() {
let entry = LogEntry {
timestamp: "unix-ms:1751500000000".to_string(),
scope: ScopeIdentity::project("/repo"),
action: ACTION_PAGE_CREATED.to_string(),
summary: "Gcode\nmulti line summary".to_string(),
artifacts: vec!["knowledge/topics/gcode.md".into()],
};
let rendered = render_entry(&entry);
assert_eq!(
rendered,
"- unix-ms:1751500000000 [project:/repo] page_created: Gcode multi line summary \
(artifacts: knowledge/topics/gcode.md)\n"
);
assert_eq!(rendered.matches('\n').count(), 1);
}
#[test]
fn source_ingested_entry_carries_record_identity_and_raw_path() {
let record = crate::sources::SourceManifest::register(
tempfile::tempdir().expect("tempdir").path(),
crate::sources::SourceDraft::new(
"https://example.com/post",
crate::sources::SourceKind::Url,
"unix-ms:1751500000000",
b"body".to_vec(),
),
)
.expect("record registered");
let entry = source_ingested_entry(
"unix-ms:1751500000001",
&ScopeIdentity::topic("research"),
&record,
Path::new("raw/some-source.md"),
);
assert_eq!(entry.action, ACTION_SOURCE_INGESTED);
assert!(entry.summary.contains(&record.id));
assert!(entry.summary.contains("https://example.com/post"));
assert_eq!(entry.artifacts, vec![PathBuf::from("raw/some-source.md")]);
}
#[test]
fn does_not_append_twice_when_scope_and_global_logs_match() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join("hub");
fs::create_dir_all(&root).expect("hub root");
let entry = LogEntry {
timestamp: "2026-05-29T19:00:00Z".to_string(),
scope: ScopeIdentity::topic("rust"),
action: "query".to_string(),
summary: "Answered ownership question".to_string(),
artifacts: vec![],
};
let report = append_logs(&root, Some(&root), &entry).expect("logs are appended once");
assert_eq!(report.scope_log, root.join("log.md"));
assert_eq!(report.global_log, Some(root.join("log.md")));
let log = fs::read_to_string(root.join("log.md")).expect("log written");
assert_eq!(log.matches("Answered ownership question").count(), 1);
}
}