mod adapters;
mod markers;
pub use adapters::{SourceFileAdapter, TEMPLATE_VERSION};
use crate::IntegrationMode;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderedSourceFile {
pub relative_path: &'static str,
pub adapter_id: &'static str,
pub template_version: u32,
pub begin_marker: String,
pub end_marker: String,
pub managed_block: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ApplyOutcome {
Created,
NoOp,
Updated,
StaleReplaced,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ApplyError {
UnbalancedMarkers,
MultipleManagedSections,
AdapterMismatch {
existing: String,
rendering: &'static str,
},
NewerTemplateVersion { existing: u32, current: u32 },
}
impl std::fmt::Display for ApplyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnbalancedMarkers => {
f.write_str("managed-section begin marker has no matching end marker")
}
Self::MultipleManagedSections => {
f.write_str("more than one Lifeloop managed section in file")
}
Self::AdapterMismatch {
existing,
rendering,
} => write!(
f,
"existing managed section is for adapter `{existing}`, rendering for `{rendering}`",
),
Self::NewerTemplateVersion { existing, current } => write!(
f,
"existing managed section has template version {existing}, this Lifeloop renders v{current}",
),
}
}
}
impl std::error::Error for ApplyError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ApplyResult {
pub outcome: ApplyOutcome,
pub body: Option<String>,
}
pub fn render_for(
adapter: SourceFileAdapter,
integration_mode: IntegrationMode,
client_slot: Option<&str>,
) -> RenderedSourceFile {
let begin_marker = markers::render_begin(adapter.as_str(), TEMPLATE_VERSION);
let end_marker = markers::END_MARKER.to_owned();
let mut body = String::new();
body.push_str(&begin_marker);
body.push('\n');
body.push('\n');
body.push_str("Lifeloop manages this section. Edits between these markers are\n");
body.push_str("overwritten on the next `lifeloop` apply. Edit anywhere outside\n");
body.push_str("the markers freely; Lifeloop will not touch user-authored content.\n");
body.push('\n');
body.push_str(&format!("- adapter: `{}`\n", adapter.as_str()));
body.push_str(&format!(
"- integration_mode: `{}`\n",
integration_mode_as_str(integration_mode)
));
body.push_str(&format!(
"- mode_summary: {}\n",
adapters::describe_integration_mode(integration_mode)
));
body.push_str(&format!("- template_version: {TEMPLATE_VERSION}\n"));
body.push('\n');
body.push_str("Lifecycle event timing:\n");
for line in adapters::lifecycle_timing_summary(adapter, integration_mode) {
body.push_str(&format!("- {line}\n"));
}
body.push('\n');
let assets = adapters::host_asset_paths(adapter, integration_mode);
if assets.is_empty() {
body.push_str("Host integration assets: none for this mode.\n");
} else {
body.push_str("Host integration assets:\n");
for path in assets {
body.push_str(&format!("- `{path}`\n"));
}
}
body.push('\n');
body.push_str("<!-- CLIENT-SLOT:BEGIN -->\n");
if let Some(slot) = client_slot {
body.push_str(slot);
if !slot.ends_with('\n') {
body.push('\n');
}
}
body.push_str("<!-- CLIENT-SLOT:END -->\n");
body.push('\n');
body.push_str(&end_marker);
body.push('\n');
RenderedSourceFile {
relative_path: adapter.relative_path(),
adapter_id: adapter.as_str(),
template_version: TEMPLATE_VERSION,
begin_marker,
end_marker,
managed_block: body,
}
}
fn integration_mode_as_str(mode: IntegrationMode) -> &'static str {
match mode {
IntegrationMode::ManualSkill => "manual_skill",
IntegrationMode::LauncherWrapper => "launcher_wrapper",
IntegrationMode::NativeHook => "native_hook",
IntegrationMode::ReferenceAdapter => "reference_adapter",
IntegrationMode::TelemetryOnly => "telemetry_only",
}
}
pub fn apply(
existing: Option<&str>,
rendered: &RenderedSourceFile,
) -> Result<ApplyResult, ApplyError> {
let Some(body) = existing else {
return Ok(ApplyResult {
outcome: ApplyOutcome::Created,
body: Some(rendered.managed_block.clone()),
});
};
let section = locate_managed_section(body)?;
let Some(section) = section else {
let mut new_body = String::with_capacity(body.len() + rendered.managed_block.len() + 1);
new_body.push_str(body);
if !body.is_empty() && !body.ends_with('\n') {
new_body.push('\n');
}
if !body.is_empty() && !body.ends_with("\n\n") {
new_body.push('\n');
}
new_body.push_str(&rendered.managed_block);
return Ok(ApplyResult {
outcome: ApplyOutcome::Created,
body: Some(new_body),
});
};
if section.meta.adapter_id != rendered.adapter_id {
return Err(ApplyError::AdapterMismatch {
existing: section.meta.adapter_id,
rendering: rendered.adapter_id,
});
}
if section.meta.template_version > rendered.template_version {
return Err(ApplyError::NewerTemplateVersion {
existing: section.meta.template_version,
current: rendered.template_version,
});
}
if section.full_text == rendered.managed_block {
return Ok(ApplyResult {
outcome: ApplyOutcome::NoOp,
body: None,
});
}
let outcome = if section.meta.template_version < rendered.template_version {
ApplyOutcome::StaleReplaced
} else {
ApplyOutcome::Updated
};
let mut new_body = String::with_capacity(body.len());
new_body.push_str(&body[..section.start_byte]);
new_body.push_str(&rendered.managed_block);
new_body.push_str(&body[section.end_byte..]);
Ok(ApplyResult {
outcome,
body: Some(new_body),
})
}
#[derive(Debug)]
struct LocatedSection {
start_byte: usize,
end_byte: usize,
full_text: String,
meta: markers::BeginMeta,
}
fn locate_managed_section(body: &str) -> Result<Option<LocatedSection>, ApplyError> {
let mut found: Option<LocatedSection> = None;
let mut byte_cursor = 0usize;
let mut state: Option<(usize, markers::BeginMeta)> = None;
for line in body.split_inclusive('\n') {
let line_start = byte_cursor;
let line_end = byte_cursor + line.len();
byte_cursor = line_end;
let content = line.strip_suffix('\n').unwrap_or(line);
if let Some(meta) = markers::parse_begin(content) {
if state.is_some() {
return Err(ApplyError::UnbalancedMarkers);
}
state = Some((line_start, meta));
continue;
}
if markers::is_end(content) {
let Some((begin_start, meta)) = state.take() else {
continue;
};
let full_text = body[begin_start..line_end].to_owned();
let located = LocatedSection {
start_byte: begin_start,
end_byte: line_end,
full_text,
meta,
};
if found.is_some() {
return Err(ApplyError::MultipleManagedSections);
}
found = Some(located);
}
}
if state.is_some() {
return Err(ApplyError::UnbalancedMarkers);
}
Ok(found)
}