pmacs/src/instance_buffer.rs

455 lines
16 KiB
Rust

// instance_buffer.rs --- T M5.6f *pmacs-instance* observability buffer renderer.
//! `*pmacs-instance*` buffer rendering ([T M5.6f]).
//!
//! The Rust side of the `editor.describe-instance` and
//! `editor.describe-instance-buffer` commands. Mirrors the renderer
//! pattern in [`crate::workers_buffer`]: a description goes in,
//! formatted text replaces the buffer's contents, the buffer is marked
//! clean.
//!
//! # Two surfaces
//!
//! * [`format_echo_line`] returns a single-line summary suitable for
//! the status line. Used by `editor.describe-instance`.
//! * [`render`] writes a multi-section detail view into the
//! `*pmacs-instance*` buffer. Used by
//! `editor.describe-instance-buffer`.
//!
//! Both consume an [`InstanceIdentity`] for the running process plus
//! an optional [`AttachmentHandle`]. In v0.1 Local mode the attachment
//! is always `None` (the process is its own instance), so the
//! formatters degrade gracefully to "describe self." Once the v0.2
//! reconnect / outbound attach path lands, the same formatters work
//! for the attached case without changes.
use std::fmt::Write;
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::buffer_registry::BufferRegistry;
use crate::protocol::{AttachmentHandle, InstanceIdentity};
/// Canonical name for the instance description buffer.
pub const INSTANCE_BUFFER_NAME: &str = "*pmacs-instance*";
/// One-line summary for the status row.
///
/// Two shapes, depending on whether `attachment` is set:
///
/// * `None`: `pmacs <ver> [<name>]: <cwd> (uptime <duration>)` — the
/// running process describes itself.
/// * `Some(_)`: `pmacs <ver> attached to <attached_name> (<target>)
/// from <cwd>, uptime <duration>` — the running process is talking
/// to a remote instance whose identity it has captured.
#[must_use]
pub fn format_echo_line(
identity: &InstanceIdentity,
attachment: Option<&AttachmentHandle>,
) -> String {
let uptime = format_uptime(identity.uptime_secs);
match attachment {
None => {
let name = identity.instance_name.as_deref().unwrap_or("local");
format!(
"pmacs {ver} [{name}]: {cwd} (uptime {uptime})",
ver = identity.pmacs_version,
cwd = identity.working_directory,
)
}
Some(h) => {
let attached_name = h.identity.instance_name.as_deref().unwrap_or("anonymous");
format!(
"pmacs {ver} attached to {attached_name} ({target}) from {cwd}, uptime {uptime}",
ver = identity.pmacs_version,
target = h.target,
cwd = identity.working_directory,
)
}
}
}
/// Render the full description into the `*pmacs-instance*` buffer
/// (creating it if absent), replacing its full contents. Returns
/// the buffer id and the Edits produced by the replacement.
///
/// # Post-audit-round-6 F31 — broadcast queueing
///
/// Returning the Edits lets the caller queue any `crdt_op` they
/// carry via `EditorCore::queue_daemon_origin_crdt_op`. See the
/// equivalent doc on `workers_buffer::render`.
///
/// The buffer is marked clean — the modeline shouldn't claim unsaved
/// changes for a generated buffer.
pub fn render(
registry: &mut BufferRegistry,
identity: &InstanceIdentity,
attachment: Option<&AttachmentHandle>,
) -> (BufferId, Vec<crate::rope::Edit>) {
let text = format_full_text(identity, attachment);
let id = registry
.find_by_name(INSTANCE_BUFFER_NAME)
.unwrap_or_else(|| registry.create(INSTANCE_BUFFER_NAME));
let buf = registry.get_mut(id).expect("just resolved");
let mut edits = Vec::new();
if buffer_contents_equal(buf, &text) {
buf.mark_clean();
return (id, edits);
}
if !buf.is_empty() {
let len = buf.len();
if let Ok(edit) = buf.apply_edit(EditOp::Delete {
range: crate::rope::Range::new(0, len),
}) {
edits.push(edit);
}
}
if !text.is_empty()
&& let Ok(edit) = buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: text.as_bytes(),
})
{
edits.push(edit);
}
buf.mark_clean();
(id, edits)
}
fn buffer_contents_equal(buf: &Buffer, text: &str) -> bool {
if buf.len() != text.len() as u64 {
return false;
}
let mut bytes = vec![0u8; text.len()];
if !bytes.is_empty() {
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
}
bytes == text.as_bytes()
}
/// Multi-section text payload for the buffer view. Sections:
///
/// 1. **This instance** — version, build hash, name, cwd, uptime.
/// 2. **Attachment** — only when `attachment` is `Some`. Shows the
/// target string and the remote identity.
/// 3. **Notes** — short hints about how to dismiss the buffer
/// (`q` keybinding) and about the v0.1 single-instance mode.
#[must_use]
pub fn format_full_text(
identity: &InstanceIdentity,
attachment: Option<&AttachmentHandle>,
) -> String {
let mut text = String::new();
let _ = writeln!(text, "This instance");
let _ = writeln!(text, " version: {}", identity.pmacs_version);
let _ = writeln!(
text,
" build hash: {}",
identity.build_hash.as_deref().unwrap_or("(unknown)")
);
let _ = writeln!(
text,
" instance name: {}",
identity.instance_name.as_deref().unwrap_or("(unnamed)")
);
let _ = writeln!(text, " working directory: {}", identity.working_directory);
let _ = writeln!(
text,
" uptime: {} ({} seconds)",
format_uptime(identity.uptime_secs),
identity.uptime_secs
);
let _ = writeln!(text);
match attachment {
None => {
let _ = writeln!(text, "Attachment");
let _ = writeln!(
text,
" (no outbound attachment — this process is its own instance)"
);
}
Some(h) => {
let _ = writeln!(text, "Attachment");
let _ = writeln!(text, " frontend id: {}", h.frontend_id.0);
let _ = writeln!(text, " target: {}", h.target);
let _ = writeln!(text, " kind: {}", h.target.kind_name());
let _ = writeln!(text, " remote version: {}", h.identity.pmacs_version);
let _ = writeln!(
text,
" remote name: {}",
h.identity.instance_name.as_deref().unwrap_or("(unnamed)")
);
let _ = writeln!(
text,
" remote cwd: {}",
h.identity.working_directory
);
let _ = writeln!(
text,
" remote uptime: {} ({} seconds)",
format_uptime(h.identity.uptime_secs),
h.identity.uptime_secs
);
if let Some(hash) = h.identity.build_hash.as_deref() {
let _ = writeln!(text, " remote build hash: {hash}");
}
}
}
let _ = writeln!(text);
let _ = writeln!(text, "Notes");
let _ = writeln!(text, " press `q` to kill this buffer.");
let _ = writeln!(
text,
" the buffer is regenerated each time you run `editor.describe-instance-buffer`."
);
text
}
/// Render `seconds` as a short human-readable duration. Mirrors the
/// scale buckets in `crate::workers_buffer::format_duration_ms` but in
/// seconds (uptimes are rarely sub-second-interesting).
fn format_uptime(seconds: u64) -> String {
if seconds < 60 {
format!("{seconds}s")
} else if seconds < 3_600 {
format!("{}m{:02}s", seconds / 60, seconds % 60)
} else if seconds < 86_400 {
let hours = seconds / 3_600;
let minutes = (seconds % 3_600) / 60;
format!("{hours}h{minutes:02}m")
} else {
let days = seconds / 86_400;
let hours = (seconds % 86_400) / 3_600;
format!("{days}d{hours:02}h")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{AttachTarget, FrontendId, InstanceIdentity};
use std::path::PathBuf;
fn local_identity() -> InstanceIdentity {
InstanceIdentity {
pmacs_version: "0.1.0".into(),
build_hash: Some("abc123".into()),
instance_name: None,
uptime_secs: 47,
working_directory: "/tmp/proj".into(),
}
}
fn named_identity() -> InstanceIdentity {
InstanceIdentity {
pmacs_version: "0.1.0".into(),
build_hash: None,
instance_name: Some("work".into()),
uptime_secs: 4_500,
working_directory: "/srv/www".into(),
}
}
fn sample_attachment() -> AttachmentHandle {
AttachmentHandle::new(
FrontendId(2),
named_identity(),
AttachTarget::LocalSocket(PathBuf::from("/run/pmacs/work.sock")),
)
}
// ---- format_echo_line ---------------------------------------------------
#[test]
fn echo_unattached_shows_local_name_when_no_instance_name() {
let line = format_echo_line(&local_identity(), None);
assert!(line.starts_with("pmacs 0.1.0 [local]"), "{line}");
assert!(line.contains("/tmp/proj"));
assert!(line.contains("uptime 47s"));
}
#[test]
fn echo_unattached_uses_instance_name_when_present() {
let line = format_echo_line(&named_identity(), None);
assert!(line.starts_with("pmacs 0.1.0 [work]"), "{line}");
assert!(line.contains("/srv/www"));
// 4500 seconds → 1h15m.
assert!(line.contains("uptime 1h15m"), "{line}");
}
#[test]
fn echo_attached_shows_remote_name_and_target() {
let h = sample_attachment();
let line = format_echo_line(&local_identity(), Some(&h));
assert!(line.contains("attached to work"), "{line}");
assert!(line.contains("local:/run/pmacs/work.sock"), "{line}");
assert!(line.contains("from /tmp/proj"));
}
#[test]
fn echo_attached_falls_back_to_anonymous_when_remote_name_missing() {
let mut id = named_identity();
id.instance_name = None;
let h = AttachmentHandle::new(
FrontendId(2),
id,
AttachTarget::LocalSocket(PathBuf::from("/x")),
);
let line = format_echo_line(&local_identity(), Some(&h));
assert!(line.contains("attached to anonymous"), "{line}");
}
#[test]
fn echo_line_is_single_line() {
let line = format_echo_line(&local_identity(), None);
assert!(!line.contains('\n'), "echo line must not contain newlines");
let h = sample_attachment();
let line = format_echo_line(&local_identity(), Some(&h));
assert!(!line.contains('\n'), "echo line must not contain newlines");
}
// ---- format_full_text ---------------------------------------------------
#[test]
fn full_text_unattached_includes_self_section_and_no_attachment_marker() {
let text = format_full_text(&local_identity(), None);
assert!(text.contains("This instance"));
assert!(text.contains("version: 0.1.0"));
assert!(text.contains("build hash: abc123"));
assert!(text.contains("instance name: (unnamed)"));
assert!(text.contains("working directory: /tmp/proj"));
assert!(text.contains("uptime:"));
assert!(text.contains("(no outbound attachment"));
assert!(text.contains("Notes"));
assert!(text.contains("press `q` to kill"));
}
#[test]
fn full_text_unnamed_build_hash_renders_as_unknown() {
let text = format_full_text(&named_identity(), None);
assert!(text.contains("build hash: (unknown)"));
assert!(text.contains("instance name: work"));
}
#[test]
fn full_text_attached_renders_remote_section() {
let h = sample_attachment();
let text = format_full_text(&local_identity(), Some(&h));
assert!(text.contains("Attachment"));
assert!(text.contains("frontend id: 2"));
assert!(text.contains("target: local:/run/pmacs/work.sock"));
assert!(text.contains("kind: local"));
assert!(text.contains("remote version: 0.1.0"));
assert!(text.contains("remote name: work"));
assert!(text.contains("remote cwd: /srv/www"));
assert!(text.contains("remote uptime: 1h15m (4500 seconds)"));
// build_hash on the remote is None, so the line should be omitted.
assert!(
!text.contains("remote build hash:"),
"expected no build-hash line for None, got: {text}"
);
}
#[test]
fn full_text_attached_includes_remote_build_hash_when_present() {
let mut id = named_identity();
id.build_hash = Some("def456".into());
let h = AttachmentHandle::new(
FrontendId(3),
id,
AttachTarget::LocalSocket(PathBuf::from("/x")),
);
let text = format_full_text(&local_identity(), Some(&h));
assert!(text.contains("remote build hash: def456"));
}
// ---- format_uptime ------------------------------------------------------
#[test]
fn format_uptime_buckets() {
assert_eq!(format_uptime(0), "0s");
assert_eq!(format_uptime(47), "47s");
assert_eq!(format_uptime(60), "1m00s");
assert_eq!(format_uptime(125), "2m05s");
assert_eq!(format_uptime(3_600), "1h00m");
assert_eq!(format_uptime(4_500), "1h15m");
assert_eq!(format_uptime(86_400), "1d00h");
assert_eq!(format_uptime(90_000), "1d01h");
}
// ---- render -------------------------------------------------------------
#[test]
fn render_creates_named_buffer_and_writes_text() {
let mut reg = BufferRegistry::new();
let (id, _edits) = render(&mut reg, &local_identity(), None);
let buf = reg.get(id).expect("just rendered");
assert_eq!(buf.name(), INSTANCE_BUFFER_NAME);
let len = buf.len();
let mut bytes = vec![0u8; usize::try_from(len).unwrap()];
if len > 0 {
buf.snapshot_rope().slice(0, len, &mut bytes);
}
let body = String::from_utf8(bytes).unwrap();
assert!(body.contains("This instance"));
assert!(body.contains("version: 0.1.0"));
assert!(body.contains("(no outbound attachment"));
assert!(!buf.is_modified(), "rendered buffer must be marked clean");
}
#[test]
fn render_replaces_existing_contents_on_second_call() {
let mut reg = BufferRegistry::new();
let (id1, _) = render(&mut reg, &local_identity(), None);
let (id2, _) = render(&mut reg, &named_identity(), None);
assert_eq!(id1, id2, "render must reuse the named buffer");
let buf = reg.get(id2).expect("rendered");
let len = buf.len();
let mut bytes = vec![0u8; usize::try_from(len).unwrap()];
if len > 0 {
buf.snapshot_rope().slice(0, len, &mut bytes);
}
let body = String::from_utf8(bytes).unwrap();
assert!(body.contains("instance name: work"));
assert!(
!body.contains("instance name: (unnamed)"),
"stale text from first render leaked: {body}"
);
}
#[test]
fn render_same_identity_is_no_op() {
let mut reg = BufferRegistry::new();
let (_id, first_edits) = render(&mut reg, &local_identity(), None);
assert!(
!first_edits.is_empty(),
"initial render should create buffer contents"
);
let (_id, second_edits) = render(&mut reg, &local_identity(), None);
assert!(
second_edits.is_empty(),
"unchanged instance render must not emit delete/insert edits"
);
}
#[test]
fn render_with_attachment_includes_remote_section() {
let mut reg = BufferRegistry::new();
let h = sample_attachment();
let (id, _) = render(&mut reg, &local_identity(), Some(&h));
let buf = reg.get(id).expect("rendered");
let len = buf.len();
let mut bytes = vec![0u8; usize::try_from(len).unwrap()];
if len > 0 {
buf.snapshot_rope().slice(0, len, &mut bytes);
}
let body = String::from_utf8(bytes).unwrap();
assert!(body.contains("frontend id: 2"));
assert!(body.contains("target: local:/run/pmacs/work.sock"));
}
}