node-app-build 6.3.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `harness-state.json` — the single index probes read to find the running
//! stack. Written by `harness up`; consumed by every other subcommand.

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Serialize, Deserialize)]
pub struct BitcoindState {
    pub mode: String,
    pub rpc_url: String,
    pub rpc_user: String,
    pub container_id: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct InstanceState {
    pub name: String,
    pub session_path: PathBuf,
    pub base_url: String,
    pub ldk_addr: String,
    pub node_id: String,
    pub pid: Option<u32>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ChannelState {
    pub from: String,
    pub to: String,
    pub capacity_sats: u64,
    pub status: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct HarnessState {
    pub created_at: String,
    pub bitcoind: BitcoindState,
    pub instances: Vec<InstanceState>,
    pub channel: Option<ChannelState>,
    /// PID of the foreground `harness up` supervisor process. `harness down`
    /// (Task 7) signals this PID to trigger clean teardown. `None` for state
    /// written outside a supervisor context.
    #[serde(default)]
    pub supervisor_pid: Option<u32>,
}

pub fn state_path() -> Result<PathBuf> {
    let base = match std::env::var("XDG_CACHE_HOME") {
        Ok(c) if !c.is_empty() => PathBuf::from(c),
        _ => {
            let home = std::env::var_os("HOME")
                .ok_or_else(|| anyhow!("$HOME not set"))?;
            PathBuf::from(home).join(".cache")
        }
    };
    Ok(base.join("node-app").join("harness").join("harness-state.json"))
}

impl HarnessState {
    pub fn load() -> Result<Self> {
        let path = state_path()?;
        let bytes = std::fs::read(&path).with_context(|| {
            format!(
                "no harness state at {} — run `node-app harness up` first",
                path.display()
            )
        })?;
        serde_json::from_slice(&bytes).context("parse harness-state.json")
    }

    pub fn save(&self) -> Result<PathBuf> {
        let path = state_path()?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).with_context(|| {
                format!("create harness state dir {}", parent.display())
            })?;
        }
        let json = serde_json::to_string_pretty(self).context("serialize harness state")?;
        std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
        Ok(path)
    }

    pub fn instance(&self, name: &str) -> Result<&InstanceState> {
        self.instances
            .iter()
            .find(|i| i.name == name)
            .ok_or_else(|| anyhow!("unknown instance '{name}'; known: {}", self.known_names()))
    }

    fn known_names(&self) -> String {
        self.instances
            .iter()
            .map(|i| i.name.as_str())
            .collect::<Vec<_>>()
            .join(", ")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn state_serializes_and_round_trips() {
        let state = HarnessState {
            created_at: "2026-07-01T00:00:00Z".into(),
            bitcoind: BitcoindState {
                mode: "docker".into(),
                rpc_url: "http://127.0.0.1:18443".into(),
                rpc_user: "polaruser".into(),
                container_id: Some("abc123".into()),
            },
            instances: vec![InstanceState {
                name: "alice".into(),
                session_path: "/tmp/alice-agent-session.json".into(),
                base_url: "http://127.0.0.1:3001".into(),
                ldk_addr: "127.0.0.1:9937".into(),
                node_id: "03aa".into(),
                pid: Some(4242),
            }],
            channel: None,
            supervisor_pid: Some(9999),
        };
        let json = serde_json::to_string(&state).unwrap();
        let back: HarnessState = serde_json::from_str(&json).unwrap();
        assert_eq!(back.instance("alice").unwrap().base_url, "http://127.0.0.1:3001");
        assert!(back.instance("bob").is_err());
        assert_eq!(back.supervisor_pid, Some(9999));
    }
}