use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use crate::error::Result;
use crate::rows;
use crate::schema::{ActivityLogEntry, Issue};
use crate::time;
const SECTION_HEADER: &str = "## Activity Log";
pub fn append(
conn: &Connection,
issue: &Issue,
kind: &str,
message: &str,
extra: &serde_json::Value,
) -> Result<ActivityLogEntry> {
let extra_str = serde_json::to_string(extra)?;
let now_str = time::format_usec(time::now_usec());
conn.execute(
"INSERT INTO activity_log_entries (issue_id, ts, kind, message, extra, \
inserted_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
params![issue.id, now_str, kind, message, extra_str, now_str],
)?;
let id = conn.last_insert_rowid();
let sql = format!(
"SELECT {} FROM activity_log_entries WHERE id = ?1",
rows::ACTIVITY_COLS
);
Ok(conn.query_row(&sql, params![id], rows::activity_log_entry)?)
}
pub fn list_since(conn: &Connection, since: DateTime<Utc>) -> Result<Vec<ActivityLogEntry>> {
let sql = format!(
"SELECT {} FROM activity_log_entries WHERE ts >= ?1 ORDER BY ts DESC",
rows::ACTIVITY_COLS
);
let mut stmt = conn.prepare(&sql)?;
let out = stmt
.query_map(params![time::format_usec(since)], rows::activity_log_entry)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(out)
}
pub fn list_for_issue(
conn: &Connection,
issue_id: i64,
limit: i64,
) -> Result<Vec<ActivityLogEntry>> {
let sql = format!(
"SELECT {} FROM activity_log_entries WHERE issue_id = ?1 \
ORDER BY ts ASC LIMIT ?2",
rows::ACTIVITY_COLS
);
let mut stmt = conn.prepare(&sql)?;
let out = stmt
.query_map(params![issue_id, limit], rows::activity_log_entry)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(out)
}
pub fn render(entries: &[ActivityLogEntry]) -> String {
entries
.iter()
.map(render_line)
.collect::<Vec<_>>()
.join("\n")
}
fn render_line(e: &ActivityLogEntry) -> String {
let kind_padded = format!("{:<8}", e.kind);
let line = format!(
"{} {} {}",
time::format_usec(e.ts),
kind_padded,
e.message
);
line.trim_end().to_string()
}
pub fn merge_activity_log_section(description: Option<&str>, body: &str) -> String {
let section = build_section(body);
match description {
None => section,
Some("") => section,
Some(desc) if has_activity_section(desc) => replace_section(desc, §ion),
Some(desc) => append_section(desc, §ion),
}
}
fn build_section(body: &str) -> String {
let trimmed = body.trim_end();
if trimmed.is_empty() {
format!("{SECTION_HEADER}\n")
} else {
format!("{SECTION_HEADER}\n\n{trimmed}\n")
}
}
fn has_activity_section(description: &str) -> bool {
find_section_start(description).is_some()
}
fn find_section_start(description: &str) -> Option<usize> {
let mut offset = 0usize;
for line in description.split_inclusive('\n') {
let trimmed_nl = line.strip_suffix('\n').unwrap_or(line);
if is_activity_header_line(trimmed_nl) {
return Some(offset);
}
offset += line.len();
}
None
}
fn is_activity_header_line(line: &str) -> bool {
if let Some(rest) = line.strip_prefix(SECTION_HEADER) {
rest.chars().all(|c| c == ' ' || c == '\t')
} else {
false
}
}
fn replace_section(description: &str, section: &str) -> String {
let start = find_section_start(description).expect("has section");
let after_header = &description[start..];
let mut end_rel = after_header.len();
let mut scanned = 0usize;
let mut first = true;
for line in after_header.split_inclusive('\n') {
if first {
first = false;
scanned += line.len();
continue;
}
let content = line.strip_suffix('\n').unwrap_or(line);
if content.starts_with("## ") {
end_rel = scanned;
break;
}
scanned += line.len();
}
let end = start + end_rel;
let mut out = String::with_capacity(description.len() + section.len());
out.push_str(&description[..start]);
out.push_str(section);
out.push('\n');
out.push_str(&description[end..]);
normalize_trailing_blank_lines(&out)
}
fn normalize_trailing_blank_lines(s: &str) -> String {
format!("{}\n", s.trim_end())
}
fn append_section(description: &str, section: &str) -> String {
let base = description.trim_end();
format!("{base}\n\n{section}")
}
#[cfg(test)]
mod tests {
use super::*;
fn has_header_line(s: &str) -> bool {
s.lines().any(is_activity_header_line)
}
#[test]
fn none_description_produces_clean_section() {
let out = merge_activity_log_section(None, "log body");
assert_eq!(out, "## Activity Log\n\nlog body\n");
}
#[test]
fn empty_description_produces_clean_section() {
let out = merge_activity_log_section(Some(""), "log body");
assert_eq!(out, "## Activity Log\n\nlog body\n");
}
#[test]
fn empty_body_produces_header_only_section() {
let out = merge_activity_log_section(None, "");
assert_eq!(out, "## Activity Log\n");
assert!(has_header_line(&out));
}
#[test]
fn appends_section_when_none_exists() {
let desc = "# Title\n\nSome prose.";
let out = merge_activity_log_section(Some(desc), "log body");
assert!(out.starts_with("# Title\n\nSome prose."));
assert!(has_header_line(&out));
let header_at = out.find(SECTION_HEADER).expect("header present");
let prose_at = out.find("Some prose.").expect("prose present");
assert!(
prose_at < header_at,
"section appended after existing content"
);
assert!(out.contains("log body"));
}
#[test]
fn replaces_existing_section_preserving_later_section() {
let desc = "# Title\n\nSome prose.\n\n## Activity Log\n\nold line\n\n## Other\n\nkept.";
let out = merge_activity_log_section(desc.into(), "new body");
assert_eq!(
out.lines().filter(|l| is_activity_header_line(l)).count(),
1
);
assert!(!out.contains("old line"), "old activity body replaced");
assert!(out.contains("new body"), "new activity body present");
assert!(out.contains("Some prose."));
assert!(out.contains("## Other"));
assert!(out.contains("kept."));
let prose = out.find("Some prose.").unwrap();
let activity = out.find(SECTION_HEADER).unwrap();
let other = out.find("## Other").unwrap();
assert!(prose < activity && activity < other);
}
}