use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use argonauts_core::events::session::{EndReason, InputPart, InputPartKind};
use argonauts_core::hash::sha256_hex;
use argonauts_core::names::{RoleName, SessionKey};
use argonauts_core::session_runner::{Access, SessionControl};
use argonauts_core::settings::delivery::DeliveryCommits;
use argonauts_core::steps::last_json_object;
use crate::engine::host::{Fallbacks, Host, SessionPlan};
use crate::engine::shared::lock;
use crate::error::{EngineError, EngineResult};
use crate::git::GitError;
use crate::labels::task_label;
use crate::merge::{final_check, merge_packet, merger, your_folder};
use crate::prompts;
use crate::task::support::part;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Kept {
change: String,
subject: String,
body: String,
}
#[derive(Debug, Deserialize)]
struct Answer {
subject: String,
#[serde(default)]
body: String,
}
const DIFF_CHARS: usize = 60_000;
#[derive(Debug, Default)]
pub struct Writing {
stopped: AtomicBool,
session: Mutex<Option<SessionKey>>,
}
impl Writing {
pub fn stop(&self, host: &Host) {
self.stopped.store(true, Ordering::SeqCst);
if let Some(session) = lock(&self.session).as_ref() {
let _ = host.control(session, SessionControl::Stop);
}
}
fn stopped(&self) -> bool {
self.stopped.load(Ordering::SeqCst)
}
fn started(&self, host: &Host, session: &SessionKey) {
*lock(&self.session) = Some(session.clone());
if self.stopped() {
let _ = host.control(session, SessionControl::Stop);
}
}
}
pub async fn write(host: &Host, writing: &Writing) -> EngineResult<()> {
if host.settings().delivery.commits == DeliveryCommits::AsRun {
return Ok(());
}
if your_folder::is(host) {
return Ok(());
}
let Some(target) = final_check::lands_on(host).await? else {
return Ok(());
};
let git = host.git();
let (_, worktree) = merge_packet::landing_branch(host).await?;
let main = final_check::main_commit(host, &target).await?;
let head = git.resolve(&worktree, "HEAD").await?;
if main == head || !git.is_ancestor(&worktree, &main, &head).await? {
return Ok(());
}
let (main, head) =
merge_packet::shown_range(host, &worktree, Some(&main), &main, &head).await?;
let change = change_of(host, &worktree, &main, &head).await?;
if read(host).await.is_some_and(|kept| kept.change == change) {
return Ok(());
}
let plan = SessionPlan {
at: host.read(merger::merge_step),
role: RoleName::Worker,
worker: merger::model(host.settings()),
task: None,
access: Access::NoTools,
step: None,
workdir: worktree.clone(),
parts: parts(host, &worktree, &main, &head).await?,
json_schema: Some(schema()),
resume: None,
slot: 0,
label: Some(merger::COMMIT_MESSAGE.to_owned()),
reviews: false,
fresh: true,
independent_of: Vec::new(),
in_place_of: None,
fork_of: None,
};
if writing.stopped() {
return Ok(());
}
let started = |session: &SessionKey| {
writing.started(host, session);
Ok(())
};
let ran = host
.run_session_in_turn(plan, None, Fallbacks::None, &started)
.await;
let ran = match ran {
Ok(ran) => ran,
Err(EngineError::Runner(_)) => return Ok(()),
Err(err) => return Err(err),
};
let Some(answer) = answer_of(&ran.end) else {
return Ok(());
};
let kept = Kept {
change,
subject: answer.subject.trim().to_owned(),
body: answer.body.trim().to_owned(),
};
let path = path(host);
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir).await?;
}
tokio::fs::write(&path, serde_json::to_vec_pretty(&kept)?).await?;
Ok(())
}
pub async fn for_change(
host: &Host,
dir: &Path,
main: &str,
head: &str,
) -> Result<Option<String>, GitError> {
let Some(kept) = read(host).await else {
return Ok(None);
};
if kept.change != change_of(host, dir, main, head).await? {
return Ok(None);
}
Ok(Some(if kept.body.is_empty() {
kept.subject
} else {
format!("{}\n\n{}", kept.subject, kept.body)
}))
}
async fn change_of(host: &Host, dir: &Path, main: &str, head: &str) -> Result<String, GitError> {
let diff = host.git().diff(dir, main, head).await?;
Ok(sha256_hex(diff.as_bytes()))
}
async fn read(host: &Host) -> Option<Kept> {
let bytes = tokio::fs::read(path(host)).await.ok()?;
serde_json::from_slice(&bytes).ok()
}
fn path(host: &Host) -> PathBuf {
host.repo().run_dir.join("merge").join("message.json")
}
fn answer_of(end: &argonauts_core::events::session::SessionEnd) -> Option<Answer> {
if end.reason != EndReason::Completed {
return None;
}
let value = end
.structured
.clone()
.filter(Value::is_object)
.or_else(|| end.result_text.as_deref().and_then(last_json_object))?;
serde_json::from_value::<Answer>(value)
.ok()
.filter(|answer| !answer.subject.trim().is_empty())
}
fn schema() -> Value {
json!({
"type": "object",
"properties": {
"subject": {
"type": "string",
"description": "What the change does, in the imperative, under 72 characters."
},
"body": {
"type": "string",
"description": "What changed and why. Empty when the subject says it all."
}
},
"required": ["subject", "body"],
"additionalProperties": false
})
}
async fn parts(
host: &Host,
worktree: &Path,
main: &str,
head: &str,
) -> Result<Vec<InputPart>, GitError> {
let files = host.git().diff_files(worktree, main, head).await?;
let (title, tasks) = host.read(|state| {
let title = state
.run
.started
.as_ref()
.map(|started| started.title.clone())
.unwrap_or_default();
let tasks: Vec<String> = state
.tasks
.by_id
.iter()
.filter(|(_, record)| merge_packet::in_the_merge(record))
.map(|(id, record)| format!("- {}: {}", task_label(state, id), record.spec.title))
.collect();
(title, tasks)
});
let mut change = format!("Asked for: {title}\n");
if !tasks.is_empty() {
change.push_str(&format!("\nWhat it holds:\n{}\n", tasks.join("\n")));
}
change.push_str("\nFiles:\n");
for (path, insertions, deletions) in &files {
change.push_str(&format!("- {path} (+{insertions} -{deletions})\n"));
}
let diff = host.git().diff(worktree, main, head).await?;
change.push_str("\nThe diff:\n");
match diff.char_indices().nth(DIFF_CHARS) {
Some((cut, _)) => {
change.push_str(&diff[..cut]);
change.push_str(&format!(
"\n\nThe diff goes on. It is cut here, after {DIFF_CHARS} characters; the file \
list above is whole."
));
}
None => change.push_str(&diff),
}
Ok(vec![
part(
InputPartKind::Prompt,
"merger",
prompts::files::text(host, prompts::merge::COMMIT_MESSAGE),
None,
),
part(InputPartKind::Message, "change", change, None),
])
}