use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use chrono::{DateTime, TimeDelta, Utc};
use fs4::fs_std::FileExt;
use netsky_core::config::owner_name;
use netsky_core::consts::{
ENV_TICKER_INTERVAL, NETSKY_BIN, TICK_INTERVAL_CONFIG, TICK_LAST_MARKER, TICK_MIN_INTERVAL_S,
TICKER_INTERVAL_DEFAULT_S, TICKER_LOG_PATH, TICKER_LOG_ROTATE_BYTES, TICKER_SESSION, TMUX_BIN,
};
use netsky_core::paths::{agent0_inbox_dir, resolve_netsky_dir, ticker_missing_count_file};
use netsky_core::process::run_bounded;
use netsky_sh::tmux;
use serde_json::json;
use crate::cli::TickCommand;
use crate::observability;
const TICK_REQUEST_TEXT: &str = include_str!("../../prompts/tick-request.md");
const TICK_REQUEST_FROM: &str = "agentinfinity";
const ANALYTICS_REFRESH_LOCK_PATH: &str = "/tmp/netsky-analytics-refresh.lock";
const ANALYTICS_REFRESH_HOUR_PATH: &str = "/tmp/netsky-analytics-refresh-hour";
const ANALYTICS_REFRESH_COMMIT_MSG: &str = "chore(analytics): ticker refresh";
const ANALYTICS_REFRESH_STALE_AFTER_MINUTES: i64 = 15;
const ENV_ANALYTICS_WEBSITE_DIR: &str = "NETSKY_ANALYTICS_WEBSITE_DIR";
const ANALYTICS_COMMAND_TIMEOUT_S: u64 = 60;
const GIT_OPERATION_TIMEOUT_S: u64 = 30;
const GIT_STDERR_REDACTION_TOKENS: [&str; 6] = [
"ghp_",
"gho_",
"github_pat_",
"://x-access-token:",
"://oauth2:",
"credential-helper",
];
pub fn run(sub: TickCommand) -> netsky_core::Result<()> {
match sub {
TickCommand::Enable { seconds } => enable(seconds),
TickCommand::Disable => disable(),
TickCommand::Request => request(),
TickCommand::Ticker => ticker_loop(),
TickCommand::Start => ticker_start(),
}
}
fn enable(seconds: u64) -> netsky_core::Result<()> {
if seconds < TICK_MIN_INTERVAL_S {
netsky_core::bail!("interval must be >= {TICK_MIN_INTERVAL_S}s (got {seconds})");
}
atomic_write(Path::new(TICK_INTERVAL_CONFIG), &seconds.to_string())?;
let _ = fs::remove_file(TICK_LAST_MARKER);
println!("[netsky tick enable] enabled at {seconds}s; next tick on next watchdog pass (~2min)");
Ok(())
}
fn disable() -> netsky_core::Result<()> {
if Path::new(TICK_INTERVAL_CONFIG).exists() {
fs::remove_file(TICK_INTERVAL_CONFIG)?;
println!("[netsky tick disable] disabled");
} else {
println!("[netsky tick disable] already disabled (no config file)");
}
Ok(())
}
pub(crate) fn request() -> netsky_core::Result<()> {
let inbox = agent0_inbox_dir();
fs::create_dir_all(&inbox)?;
let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
let ts_iso = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.6fZ")
.to_string();
let ts_ns = format!("{}{:09}", now.as_secs(), now.subsec_nanos());
let path = inbox.join(format!("tick-{ts_ns}.json"));
let name = owner_name();
let rendered = render_tick_request(TICK_REQUEST_TEXT, &name);
let text_escaped = escape_json_string(rendered.trim());
let envelope = format!(
"{{\"from\":\"{TICK_REQUEST_FROM}\",\"text\":\"{text_escaped}\",\"ts\":\"{ts_iso}\"}}\n"
);
atomic_write(&path, &envelope)?;
println!("[netsky tick request {ts_iso}] dropped {}", path.display());
Ok(())
}
pub(crate) fn ticker_missing_count() -> u32 {
fs::read_to_string(ticker_missing_count_file())
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0)
}
pub(crate) fn ticker_missing_record(count: u32) -> netsky_core::Result<()> {
ticker_missing_record_at(&ticker_missing_count_file(), count)
}
pub(crate) fn ticker_missing_clear() -> netsky_core::Result<()> {
ticker_missing_clear_at(&ticker_missing_count_file())
}
fn ticker_missing_record_at(path: &Path, count: u32) -> netsky_core::Result<()> {
atomic_write(path, &count.to_string()).map_err(Into::into)
}
fn ticker_missing_clear_at(path: &Path) -> netsky_core::Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
fn ticker_loop() -> netsky_core::Result<()> {
let interval = std::env::var(ENV_TICKER_INTERVAL)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(TICKER_INTERVAL_DEFAULT_S);
log_line(&format!(
"[netsky-ticker] started at {}; interval={interval}s",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
loop {
let watchdog = run_tick_subprocess(["watchdog", "tick"]);
let cron = run_tick_subprocess(["cron", "tick"]);
let loop_tick = run_tick_subprocess(["loop", "tick"]);
let analytics = maybe_refresh_daily_analytics();
observability::record_tick(
"ticker",
json!({
"interval_s": interval,
"watchdog": watchdog.detail,
"cron": cron.detail,
"loop": loop_tick.detail,
"analytics": analytics,
}),
);
report_tick_result("watchdog", &watchdog);
report_tick_result("cron", &cron);
report_tick_result("loop", &loop_tick);
std::thread::sleep(Duration::from_secs(interval));
}
}
struct TickSubprocessResult {
detail: serde_json::Value,
output: std::io::Result<std::process::Output>,
}
fn run_tick_subprocess(args: [&str; 2]) -> TickSubprocessResult {
let output = Command::new(NETSKY_BIN).args(args).output();
let detail = match &output {
Ok(o) => json!({
"status": o.status.code(),
"ok": o.status.success(),
}),
Err(e) => json!({
"status": "spawn-failed",
"error": e.to_string(),
}),
};
TickSubprocessResult { detail, output }
}
fn report_tick_result(label: &str, result: &TickSubprocessResult) {
match &result.output {
Ok(output) => {
tee(&String::from_utf8_lossy(&output.stdout));
tee(&String::from_utf8_lossy(&output.stderr));
if !output.status.success() {
log_line(&format!(
"[netsky-ticker] {label} returned non-zero at {}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
}
}
Err(e) => {
log_line(&format!(
"[netsky-ticker] {label} subprocess failed at {}: {e}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
}
}
}
fn maybe_refresh_daily_analytics() -> serde_json::Value {
let now = Utc::now();
let hour_key = now.format("%Y-%m-%dT%H").to_string();
if !analytics_attempt_due(Path::new(ANALYTICS_REFRESH_HOUR_PATH), &hour_key) {
return json!({
"status": "skipped",
"reason": "already-attempted-this-hour",
"hour": hour_key,
});
}
let website_dir = analytics_website_dir();
if !website_dir.is_dir() {
return json!({
"status": "skipped",
"reason": "website-dir-missing",
"website_dir": website_dir.display().to_string(),
});
}
let repo_root = match git_repo_root(&website_dir) {
Ok(Some(root)) => root,
Ok(None) => {
return json!({
"status": "skipped",
"reason": "not-a-git-repo",
"website_dir": website_dir.display().to_string(),
});
}
Err(err) => {
log_line(&format!(
"[netsky-ticker] analytics refresh repo-root failed at {}: {err}",
now.format("%Y-%m-%dT%H:%M:%SZ")
));
return json!({
"status": "error",
"reason": "git-repo-root-failed",
"error": err,
"website_dir": website_dir.display().to_string(),
});
}
};
if let Err(err) = validate_analytics_repo_scope(&website_dir, &repo_root) {
return json!({
"status": "skipped",
"reason": "repo-out-of-scope",
"error": err,
"website_dir": website_dir.display().to_string(),
"repo_root": repo_root.display().to_string(),
});
};
let date = now.date_naive();
let page_rel = format!("website/content/analytics/{date}.md");
let page_path = repo_root.join(&page_rel);
let decision = analytics_refresh_decision_for_path(&page_path, now);
if !decision.should_refresh {
return finish_analytics_refresh(
now,
&hour_key,
json!({
"status": "skipped",
"reason": "fresh",
"page": page_path.display().to_string(),
"generated_at": decision.generated_at,
}),
);
}
let _lock = match try_lock_analytics_refresh() {
Ok(Some(lock)) => lock,
Ok(None) => {
return json!({
"status": "skipped",
"reason": "locked",
});
}
Err(err) => {
log_line(&format!(
"[netsky-ticker] analytics refresh lock failed at {}: {err}",
now.format("%Y-%m-%dT%H:%M:%SZ")
));
return json!({
"status": "error",
"reason": "lock-failed",
"error": err.to_string(),
});
}
};
let decision = analytics_refresh_decision_for_path(&page_path, Utc::now());
if !decision.should_refresh {
return finish_analytics_refresh(
Utc::now(),
&hour_key,
json!({
"status": "skipped",
"reason": "fresh-after-lock",
"page": page_path.display().to_string(),
"generated_at": decision.generated_at,
}),
);
}
let analytics_output = match run_analytics_daily(&repo_root, &website_dir, &date.to_string()) {
Ok(output) => output,
Err(err) => {
log_line(&format!(
"[netsky-ticker] analytics refresh command failed at {}: {err}",
Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
return json!({
"status": "error",
"reason": "analytics-command-failed",
"error": err,
});
}
};
tee(&String::from_utf8_lossy(&analytics_output.stdout));
tee(&String::from_utf8_lossy(&analytics_output.stderr));
if !analytics_output.success() {
log_line(&format!(
"[netsky-ticker] analytics refresh command failed at {}",
Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
return json!({
"status": "error",
"reason": "analytics-command-failed",
"exit": analytics_output.status.and_then(|status| status.code()),
"page": page_path.display().to_string(),
});
}
let has_diff = match git_path_has_diff(&repo_root, &page_rel) {
Ok(has_diff) => has_diff,
Err(err) => {
log_line(&format!(
"[netsky-ticker] analytics refresh git status failed at {}: {err}",
Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
return json!({
"status": "error",
"reason": "git-status-failed",
"error": err,
"page": page_path.display().to_string(),
});
}
};
if !has_diff {
return finish_analytics_refresh(
Utc::now(),
&hour_key,
json!({
"status": "ok",
"reason": "refreshed-no-diff",
"page": page_path.display().to_string(),
}),
);
}
if let Err(err) = git_stage_path(&repo_root, &page_rel) {
log_line(&format!(
"[netsky-ticker] analytics refresh git add failed at {}: {err}",
Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
return json!({
"status": "error",
"reason": "git-add-failed",
"error": err,
});
}
if let Err(err) = git_commit_refresh(&repo_root, &page_rel) {
log_line(&format!(
"[netsky-ticker] analytics refresh git commit failed at {}: {err}",
Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
return json!({
"status": "error",
"reason": "git-commit-failed",
"error": err,
});
}
match git_push_main(&repo_root) {
Ok(()) => finish_analytics_refresh(
Utc::now(),
&hour_key,
json!({
"status": "ok",
"reason": "refreshed-and-pushed",
"page": page_path.display().to_string(),
}),
),
Err(err) => {
log_line(&format!(
"[netsky-ticker] analytics refresh git push failed at {}: {err}",
Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
json!({
"status": "error",
"reason": "git-push-failed",
"error": err,
"page": page_path.display().to_string(),
})
}
}
}
fn analytics_website_dir() -> PathBuf {
std::env::var_os(ENV_ANALYTICS_WEBSITE_DIR)
.map(PathBuf::from)
.unwrap_or_else(|| resolve_netsky_dir().join("website"))
}
fn analytics_attempt_due(path: &Path, hour_key: &str) -> bool {
match fs::read_to_string(path) {
Ok(existing) => existing.trim() != hour_key,
Err(_) => true,
}
}
fn analytics_refresh_decision_for_path(
path: &Path,
now: DateTime<Utc>,
) -> AnalyticsRefreshDecision {
let body = match fs::read_to_string(path) {
Ok(body) => body,
Err(_) => {
return AnalyticsRefreshDecision {
should_refresh: true,
generated_at: None,
};
}
};
analytics_refresh_decision(&body, now)
}
#[derive(Debug, PartialEq, Eq)]
struct AnalyticsRefreshDecision {
should_refresh: bool,
generated_at: Option<String>,
}
fn analytics_refresh_decision(markdown: &str, now: DateTime<Utc>) -> AnalyticsRefreshDecision {
let generated_at = parse_generated_timestamp(markdown);
let should_refresh = match generated_at.as_ref() {
Some(ts) => {
if *ts > now {
true
} else {
now.signed_duration_since(*ts)
> TimeDelta::minutes(ANALYTICS_REFRESH_STALE_AFTER_MINUTES)
}
}
None => true,
};
AnalyticsRefreshDecision {
should_refresh,
generated_at: generated_at.map(|ts| ts.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
}
}
fn parse_generated_timestamp(markdown: &str) -> Option<DateTime<Utc>> {
markdown.lines().find_map(|line| {
line.trim()
.strip_prefix("*generated ")
.and_then(|rest| rest.strip_suffix('*'))
.and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
.map(|ts| ts.with_timezone(&Utc))
})
}
fn try_lock_analytics_refresh() -> std::io::Result<Option<std::fs::File>> {
let file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(ANALYTICS_REFRESH_LOCK_PATH)?;
match file.try_lock_exclusive() {
Ok(true) => Ok(Some(file)),
Ok(false) => Ok(None),
Err(err) => Err(err),
}
}
fn finish_analytics_refresh(
now: DateTime<Utc>,
hour_key: &str,
result: serde_json::Value,
) -> serde_json::Value {
if !should_write_analytics_hour_marker(&result) {
return result;
}
if let Err(err) = atomic_write(Path::new(ANALYTICS_REFRESH_HOUR_PATH), hour_key) {
log_line(&format!(
"[netsky-ticker] analytics refresh hour marker write failed at {}: {err}",
now.format("%Y-%m-%dT%H:%M:%SZ")
));
return json!({
"status": "error",
"reason": "hour-marker-write-failed",
"error": err.to_string(),
});
}
result
}
fn should_write_analytics_hour_marker(result: &serde_json::Value) -> bool {
let status = result.get("status").and_then(|v| v.as_str());
let reason = result.get("reason").and_then(|v| v.as_str());
matches!(
(status, reason),
(Some("ok"), _)
| (Some("skipped"), Some("fresh"))
| (Some("skipped"), Some("fresh-after-lock"))
)
}
fn git_repo_root(dir: &Path) -> Result<Option<PathBuf>, String> {
let mut cmd = Command::new("git");
cmd.args([
"-C",
&dir.display().to_string(),
"rev-parse",
"--show-toplevel",
]);
let output = run_bounded_command(
cmd,
Duration::from_secs(GIT_OPERATION_TIMEOUT_S),
"git rev-parse",
)?;
if !output.success() {
return Ok(None);
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
if root.is_empty() {
return Ok(None);
}
Ok(Some(PathBuf::from(root)))
}
fn validate_analytics_repo_scope(website_dir: &Path, repo_root: &Path) -> Result<(), String> {
validate_analytics_repo_scope_at(&resolve_netsky_dir(), website_dir, repo_root)
}
fn validate_analytics_repo_scope_at(
expected_repo_root: &Path,
website_dir: &Path,
repo_root: &Path,
) -> Result<(), String> {
let expected_repo_root = fs::canonicalize(expected_repo_root).map_err(|_| {
format!(
"resolved netsky dir missing: {}",
expected_repo_root.display()
)
})?;
let expected_website_dir = expected_repo_root.join("website");
let website_dir = fs::canonicalize(website_dir)
.map_err(|err| format!("website dir canonicalize failed: {err}"))?;
let repo_root = fs::canonicalize(repo_root)
.map_err(|err| format!("repo root canonicalize failed: {err}"))?;
if repo_root != expected_repo_root || website_dir != expected_website_dir {
return Err(format!(
"analytics refresh limited to {} (got repo={}, website={})",
expected_website_dir.display(),
repo_root.display(),
website_dir.display(),
));
}
Ok(())
}
fn run_analytics_daily(
repo_root: &Path,
website_dir: &Path,
date: &str,
) -> Result<netsky_core::process::BoundedOutput, String> {
let mut cmd = Command::new(NETSKY_BIN);
cmd.args([
"analytics",
"daily",
"--for",
date,
"--website",
&website_dir.display().to_string(),
])
.current_dir(repo_root);
run_bounded_command(
cmd,
Duration::from_secs(ANALYTICS_COMMAND_TIMEOUT_S),
"netsky analytics daily",
)
}
fn git_path_has_diff(repo_root: &Path, path: &str) -> Result<bool, String> {
let stdout = run_git(
repo_root,
&["status", "--porcelain", "--", path],
"git status",
)?;
Ok(!stdout.trim().is_empty())
}
fn git_stage_path(repo_root: &Path, path: &str) -> Result<(), String> {
run_git(repo_root, &["add", "--", path], "git add").map(|_| ())
}
fn git_commit_refresh(repo_root: &Path, path: &str) -> Result<(), String> {
let staged_paths = git_cached_paths(repo_root)?;
if staged_paths != [path] {
return Err(format!(
"unexpected staged paths before analytics commit: {}",
staged_paths.join(", ")
));
}
run_git(
repo_root,
&["commit", "-m", ANALYTICS_REFRESH_COMMIT_MSG, "--", path],
"git commit",
)
.map(|_| ())
}
fn git_push_main(repo_root: &Path) -> Result<(), String> {
run_git(repo_root, &["push", "origin", "main"], "git push").map(|_| ())
}
fn git_cached_paths(repo_root: &Path) -> Result<Vec<String>, String> {
let stdout = run_git(
repo_root,
&["diff", "--cached", "--name-only"],
"git diff --cached",
)?;
Ok(stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect())
}
fn run_git(repo_root: &Path, args: &[&str], label: &str) -> Result<String, String> {
let mut cmd = Command::new("git");
cmd.args(["-C", &repo_root.display().to_string()])
.args(args);
let output = run_bounded_command(cmd, Duration::from_secs(GIT_OPERATION_TIMEOUT_S), label)?;
let stdout = redact_sensitive_git_output(&String::from_utf8_lossy(&output.stdout));
let stderr = redact_sensitive_git_output(&String::from_utf8_lossy(&output.stderr));
if output.success() {
return Ok(stdout.trim().to_string());
}
if !stderr.is_empty() {
return Err(stderr.trim().to_string());
}
if !stdout.is_empty() {
return Err(stdout.trim().to_string());
}
Err(format!(
"{label} exited {:?}",
output.status.and_then(|status| status.code())
))
}
fn run_bounded_command(
cmd: Command,
timeout: Duration,
label: &str,
) -> Result<netsky_core::process::BoundedOutput, String> {
let output = run_bounded(cmd, timeout).map_err(|err| err.to_string())?;
if output.timed_out {
return Err(format!("{label} timed out after {}s", timeout.as_secs()));
}
Ok(output)
}
fn redact_sensitive_git_output(text: &str) -> String {
text.lines()
.filter(|line| {
!GIT_STDERR_REDACTION_TOKENS
.iter()
.any(|needle| line.contains(needle))
})
.collect::<Vec<_>>()
.join("\n")
}
fn tee(text: &str) {
for line in text.lines() {
if line.is_empty() {
continue;
}
log_line(line);
}
}
pub(crate) fn ticker_start() -> netsky_core::Result<()> {
if tmux::has_session(TICKER_SESSION) {
let _ = ticker_missing_clear();
println!("[ticker-start] session '{TICKER_SESSION}' already up; skipping spawn");
return Ok(());
}
let cmd = format!("{NETSKY_BIN} tick ticker");
let status = Command::new(TMUX_BIN)
.args(["new-session", "-d", "-s", TICKER_SESSION, &cmd])
.status()?;
if !status.success() {
netsky_core::bail!("tmux new-session failed for '{TICKER_SESSION}'");
}
let _ = ticker_missing_clear();
let interval = std::env::var(ENV_TICKER_INTERVAL)
.unwrap_or_else(|_| TICKER_INTERVAL_DEFAULT_S.to_string());
println!(
"[ticker-start] spawned '{TICKER_SESSION}' — watchdog ticks every {interval}s (bash sleep-loop)"
);
Ok(())
}
fn atomic_write(target: &Path, content: &str) -> std::io::Result<()> {
let tmp = target.with_extension("tmp");
fs::write(&tmp, content)?;
fs::rename(&tmp, target)
}
fn log_line(line: &str) {
println!("{line}");
maybe_rotate_log();
if let Ok(mut f) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(TICKER_LOG_PATH)
{
let _ = writeln!(f, "{line}");
}
}
fn maybe_rotate_log() {
if let Ok(md) = fs::metadata(TICKER_LOG_PATH)
&& md.len() > TICKER_LOG_ROTATE_BYTES
{
let rotated = format!("{TICKER_LOG_PATH}.1");
let _ = fs::rename(TICKER_LOG_PATH, &rotated);
}
}
fn render_tick_request(template: &str, owner_name: &str) -> String {
template
.replace("{{ owner_name }}", owner_name)
.replace("{{owner_name}}", owner_name)
}
fn escape_json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn json_escape_handles_specials() {
assert_eq!(escape_json_string("a\"b"), "a\\\"b");
assert_eq!(escape_json_string("a\\b"), "a\\\\b");
assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
}
#[test]
fn render_substitutes_owner_name() {
let out = render_tick_request("ping {{ owner_name }} now", "Cody");
assert_eq!(out, "ping Cody now");
assert!(!out.contains("{{"));
}
#[test]
fn render_tolerates_no_inner_whitespace() {
let out = render_tick_request("ping {{owner_name}} now", "Cody");
assert_eq!(out, "ping Cody now");
}
#[test]
fn render_default_keeps_baked_template_owner_neutral() {
assert!(
TICK_REQUEST_TEXT.contains("{{ owner_name }}"),
"tick-request.md lost its owner_name placeholder"
);
let rendered =
render_tick_request(TICK_REQUEST_TEXT, netsky_core::consts::OWNER_NAME_DEFAULT);
assert!(rendered.contains(netsky_core::consts::OWNER_NAME_DEFAULT));
assert!(!rendered.contains("{{"));
}
#[test]
fn ticker_missing_count_roundtrips() {
let dir = tempdir().unwrap();
let path = dir.path().join("netsky-ticker-missing-count");
ticker_missing_record_at(&path, 1).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "1");
}
#[test]
fn ticker_missing_clear_removes_state() {
let dir = tempdir().unwrap();
let path = dir.path().join("netsky-ticker-missing-count");
std::fs::write(&path, "2").unwrap();
assert!(path.exists());
ticker_missing_clear_at(&path).unwrap();
assert!(!path.exists());
}
#[test]
fn analytics_refresh_decision_skips_fresh_page() {
let now = DateTime::parse_from_rfc3339("2026-04-18T19:00:00Z")
.unwrap()
.with_timezone(&Utc);
let markdown = "+++\n+++\n\n*generated 2026-04-18T18:50:30Z*\n";
let decision = analytics_refresh_decision(markdown, now);
assert_eq!(
decision,
AnalyticsRefreshDecision {
should_refresh: false,
generated_at: Some("2026-04-18T18:50:30Z".to_string()),
}
);
}
#[test]
fn analytics_refresh_decision_refreshes_stale_page() {
let now = DateTime::parse_from_rfc3339("2026-04-18T19:00:00Z")
.unwrap()
.with_timezone(&Utc);
let markdown = "+++\n+++\n\n*generated 2026-04-18T18:40:00Z*\n";
let decision = analytics_refresh_decision(markdown, now);
assert_eq!(
decision,
AnalyticsRefreshDecision {
should_refresh: true,
generated_at: Some("2026-04-18T18:40:00Z".to_string()),
}
);
}
#[test]
fn analytics_refresh_decision_refreshes_missing_timestamp() {
let now = DateTime::parse_from_rfc3339("2026-04-18T19:00:00Z")
.unwrap()
.with_timezone(&Utc);
let decision = analytics_refresh_decision("+++\n+++\n\n", now);
assert_eq!(
decision,
AnalyticsRefreshDecision {
should_refresh: true,
generated_at: None,
}
);
}
#[test]
fn analytics_refresh_decision_refreshes_future_timestamp() {
let now = DateTime::parse_from_rfc3339("2026-04-18T19:00:00Z")
.unwrap()
.with_timezone(&Utc);
let markdown = "+++\n+++\n\n*generated 2099-01-01T00:00:00Z*\n";
let decision = analytics_refresh_decision(markdown, now);
assert_eq!(
decision,
AnalyticsRefreshDecision {
should_refresh: true,
generated_at: Some("2099-01-01T00:00:00Z".to_string()),
}
);
}
#[test]
fn analytics_commit_rejects_unrelated_staged_paths() {
let dir = tempdir().unwrap();
init_git_repo(dir.path());
let page_rel = "website/content/analytics/2026-04-18.md";
let page_path = dir.path().join(page_rel);
let other_path = dir.path().join("src/leak.rs");
fs::create_dir_all(page_path.parent().unwrap()).unwrap();
fs::create_dir_all(other_path.parent().unwrap()).unwrap();
fs::write(&page_path, "*generated 2026-04-18T19:00:00Z*\n").unwrap();
fs::write(&other_path, "fn leak() {}\n").unwrap();
git(dir.path(), &["add", "--", "src/leak.rs"]);
let err = git_commit_refresh(dir.path(), page_rel).unwrap_err();
let staged = git_stdout(dir.path(), &["diff", "--cached", "--name-only"]);
let head = git_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
assert!(err.contains("unexpected staged paths"));
assert!(staged.lines().any(|line| line.trim() == "src/leak.rs"));
assert_eq!(head.trim(), "1");
}
#[test]
fn analytics_repo_scope_rejects_redirect_repo() {
let expected = tempdir().unwrap();
let rogue = tempdir().unwrap();
init_git_repo(expected.path());
init_git_repo(rogue.path());
let rogue_website = rogue.path().join("website");
fs::create_dir_all(&rogue_website).unwrap();
let err = validate_analytics_repo_scope_at(expected.path(), &rogue_website, rogue.path())
.unwrap_err();
assert!(err.contains("analytics refresh limited to"));
}
#[test]
fn analytics_hour_marker_waits_for_success_or_fresh() {
let marker = tempdir().unwrap().path().join("hour");
let error = json!({
"status": "error",
"reason": "git-push-failed",
});
let fresh = json!({
"status": "skipped",
"reason": "fresh",
});
assert!(!should_write_analytics_hour_marker(&error));
assert!(should_write_analytics_hour_marker(&fresh));
assert!(!marker.exists());
}
#[test]
fn redact_sensitive_git_output_drops_token_lines() {
let redacted = redact_sensitive_git_output(
"ok\nremote: credential-helper says hi\nauth=ghp_secret\nsafe line",
);
assert_eq!(redacted, "ok\nsafe line");
}
fn init_git_repo(path: &Path) {
git(path, &["init", "-b", "main"]);
git(path, &["config", "user.name", "netsky"]);
git(path, &["config", "user.email", "netsky@example.com"]);
fs::write(path.join(".gitignore"), "target/\n").unwrap();
git(path, &["add", "--", ".gitignore"]);
git(path, &["commit", "-m", "init"]);
}
fn git(path: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.unwrap();
assert!(status.success(), "git {:?} failed", args);
}
fn git_stdout(path: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "git {:?} failed", args);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
}