use std::collections::BTreeMap;
use serde::Serialize;
use super::portable::{self, PortableHooks};
use super::render::{self, Skip};
use super::targets::{self, Target};
use super::{HookDef, Origin, Tokens};
use crate::ordered::OrderedValue;
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum HookOutput {
Merge(MergeOutput),
File(FileOutput),
}
impl HookOutput {
pub fn skips(&self) -> &[Skip] {
match self {
Self::Merge(output) => &output.skips,
Self::File(output) => &output.skips,
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileOutput {
pub files: Vec<OutputFile>,
pub fragment_problem: Option<&'static str>,
pub skips: Vec<Skip>,
pub answer_limits: Vec<Skip>,
}
impl FileOutput {
pub fn has_fragment_beside_generated(&self) -> bool {
self.files.len() > 1
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OutputFile {
pub path: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MergeOutput {
pub events: BTreeMap<String, Vec<OrderedValue>>,
pub skipped_events: Vec<String>,
pub unchecked_events: Vec<String>,
pub overrides: Vec<String>,
pub skips: Vec<Skip>,
}
impl MergeOutput {
fn conform_to_codex(&mut self, profile: &targets::Profile) -> Result<(), FragmentError> {
let checked: BTreeMap<_, _> = self
.events
.iter()
.filter(|(event, entries)| {
!entries.is_empty() && targets::claude_event(event).is_some()
})
.map(|(event, entries)| (event.clone(), OrderedValue::Array(entries.clone())))
.collect();
if checked.is_empty() {
return Ok(());
}
let (table, nulls) = PortableHooks::from_ordered(OrderedValue::Object(
checked
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
))
.map_err(|error| FragmentError(format!("fragment is not a hook table: {error}")))?;
for path in nulls {
let event = path.split(['.', '[']).nth(1).map(str::to_owned);
self.skips.push(render::Skip {
event,
reason: format!("`null` at `{path}` dropped: Codex cannot parse it"),
});
}
let rendered = table.render(profile, &Tokens::default());
self.skips.extend(rendered.skips.iter().cloned());
for event in checked.keys() {
self.events.remove(event);
}
self.events.extend(rendered.events().clone());
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct FragmentError(String);
impl HookDef {
pub fn output(
&self,
target: &Target,
fragment_text: Option<&str>,
origin: Origin,
tokens: &Tokens<'_>,
) -> Result<HookOutput, FragmentError> {
if target.is_file() {
Ok(HookOutput::File(self.file_output(
target,
fragment_text,
tokens,
)))
} else {
self.merge_output(target, &target.profile, fragment_text, origin, tokens)
.map(HookOutput::Merge)
}
}
fn file_output(
&self,
target: &Target,
fragment_text: Option<&str>,
tokens: &Tokens<'_>,
) -> FileOutput {
let tokens = &Tokens {
hook_name: &self.name,
..*tokens
};
let mut output = FileOutput::default();
if let (Some(text), Some(path)) = (fragment_text, target.fragment_path(&self.name)) {
let content = tokens.substitute(text);
output.fragment_problem = target.fragment_problem(&content);
output.files.push(OutputFile { path, content });
}
if let (Some(portable), true) = (&self.portable, self.generates_for(target.root)) {
let rendered = portable.render(&target.profile, tokens);
if let (
render::Output::File {
text: Some(content),
},
Some(path),
) = (rendered.output, target.generated_path(&self.name))
{
output.files.push(OutputFile { path, content });
}
output.skips = rendered.skips;
output.answer_limits = rendered.answer_limits;
}
output
}
pub fn plugin_output(
&self,
target: &Target,
shared: Option<&targets::Profile>,
fragment_text: Option<&str>,
origin: Origin,
tokens: &Tokens<'_>,
) -> Result<HookOutput, FragmentError> {
let Some(profile) = shared else {
return self.output(target, fragment_text, origin, tokens);
};
let mut output = self.merge_output(target, profile, fragment_text, origin, tokens)?;
output.conform_to_codex(profile)?;
Ok(HookOutput::Merge(output))
}
pub fn check_fragment(
&self,
target: &Target,
fragment_text: Option<&str>,
origin: Origin,
) -> Result<(), FragmentError> {
if target.is_file() {
return Ok(());
}
let tokens = Tokens::default();
self.lay_fragment(
MergeOutput::default(),
target,
&target.profile,
fragment_text,
origin,
&tokens,
)
.map(drop)
}
fn merge_output(
&self,
target: &Target,
profile: &targets::Profile,
fragment_text: Option<&str>,
origin: Origin,
tokens: &Tokens<'_>,
) -> Result<MergeOutput, FragmentError> {
let mut output = MergeOutput::default();
if let (Some(portable), true) = (&self.portable, self.generates_for(target.root)) {
let rendered = portable.render(profile, tokens);
if let render::Output::Merge {
events,
unchecked_events,
} = rendered.output
{
output.events = events;
output.unchecked_events = unchecked_events;
}
output.skips = rendered.skips;
}
self.lay_fragment(output, target, profile, fragment_text, origin, tokens)
}
fn lay_fragment(
&self,
mut output: MergeOutput,
target: &Target,
profile: &targets::Profile,
fragment_text: Option<&str>,
origin: Origin,
tokens: &Tokens<'_>,
) -> Result<MergeOutput, FragmentError> {
let (Some(text), Some(fragment)) = (fragment_text, self.fragment(target.root)) else {
return Ok(output);
};
let err = |message: String| FragmentError(message);
let mut value: OrderedValue = serde_json::from_str(text)
.map_err(|error| err(format!("fragment is not valid JSON: {error}")))?;
value = portable::unwrap_hooks_object(value);
let OrderedValue::Object(object) = value else {
return Err(err(
"fragment top level must be an event-keyed JSON object".into()
));
};
let sorted: BTreeMap<_, _> = object.into_iter().collect();
for (event, entries) in sorted {
if profile.event(&event).is_none() {
if !fragment.is_unchecked() && origin == Origin::Dependency {
output.skipped_events.push(event);
continue;
}
if !fragment.is_unchecked() {
let checked = profile
.verified_against
.map_or(String::new(), |v| format!(" (checked against {v})"));
return Err(err(format!(
"unknown event `{event}` for target `{}`; valid events: {}{checked}; use `unchecked = true` to pass a newer native event through verbatim",
target.root,
profile
.events
.iter()
.map(|e| e.name)
.collect::<Vec<_>>()
.join(", ")
)));
}
output.unchecked_events.push(event.clone());
}
let OrderedValue::Array(mut array) = entries else {
return Err(err(format!("event `{event}` value must be an array")));
};
array.iter_mut().for_each(|e| tokens.substitute_value(e));
if self.portable.as_ref().is_some_and(|p| p.declares(&event)) {
output.overrides.push(event.clone());
}
output
.skips
.retain(|skip| skip.event.as_deref() != Some(event.as_str()));
output.events.insert(event, array);
}
Ok(output)
}
}