use std::fmt::Write as _;
use crate::engine::stable_id;
use crate::intent_formalization::formalize_intent;
use crate::seed::{self, Slot};
use super::planner::Capability;
pub const PLAN_PATH: &str = ".formal-ai/general-change-plan.lino";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneralPlanStep {
pub capability: Capability,
pub action: String,
pub expected_evidence: String,
pub command: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneralChangePlan {
pub id: String,
pub goal: String,
pub target: String,
pub content: String,
pub steps: Vec<GeneralPlanStep>,
pub verification_command: String,
}
impl GeneralChangePlan {
#[must_use]
pub fn links_notation(&self) -> String {
let mut out = String::from("general_change_plan\n");
field(&mut out, "id", &self.id);
field(&mut out, "goal", &self.goal);
field(&mut out, "target", &self.target);
for (index, step) in self.steps.iter().enumerate() {
let _ = writeln!(out, " step {}", index + 1);
field_nested(&mut out, "capability", capability_slug(step.capability));
field_nested(&mut out, "action", &step.action);
field_nested(&mut out, "expected_evidence", &step.expected_evidence);
if let Some(command) = &step.command {
field_nested(&mut out, "command", command);
}
}
field(&mut out, "verification_command", &self.verification_command);
out
}
}
#[must_use]
pub fn compose_general_change_plan(request: &str) -> Option<GeneralChangePlan> {
let (target, content) = parse_write_request(request)?;
if !safe_relative_path(&target) {
return None;
}
let intent = formalize_intent(request, language(request), None);
let verification_command = format!("cat {target}");
let steps = vec![
GeneralPlanStep {
capability: Capability::Write,
action: format!("append the composed plan to {PLAN_PATH}"),
expected_evidence: format!("written plan event {}", intent.impulse_id),
command: None,
},
GeneralPlanStep {
capability: Capability::Write,
action: format!("write the requested content to {target}"),
expected_evidence: format!("workspace file {target}"),
command: None,
},
GeneralPlanStep {
capability: Capability::Run,
action: String::from("run the request-derived verification command"),
expected_evidence: content.clone(),
command: Some(verification_command.clone()),
},
];
Some(GeneralChangePlan {
id: stable_id(
"general_change_plan",
&format!("{}:{target}:{content}", intent.impulse_id),
),
goal: intent.source_text,
target,
content,
steps,
verification_command,
})
}
fn parse_write_request(request: &str) -> Option<(String, String)> {
let lowered = request.to_lowercase();
let toks = tokens(request);
let target_cues = bare_surfaces(seed::ROLE_FILE_WRITE_TARGET_CUE);
let dest_cues = bare_surfaces(seed::ROLE_FILE_WRITE_DESTINATION_CUE);
let (file_index, target) = toks.iter().enumerate().find_map(|(index, token)| {
let cleaned = clean_path_token(token.text);
let looks_like_file = cleaned.contains('.') && !cleaned.contains("://");
if !looks_like_file || !safe_relative_path(cleaned) {
return None;
}
let previous = index.checked_sub(1).map(|i| &toks[i])?;
let previous_word = clean_cue_token(previous.text);
(target_cues.contains(&previous_word) || dest_cues.contains(&previous_word))
.then(|| (index, cleaned.to_owned()))
})?;
let cue = &toks[file_index - 1];
let clause_start = cue.start;
let cue_is_destination = dest_cues.contains(&clean_cue_token(cue.text));
let content_span = if let Some((_, marker_end)) = first_content_lead_end(&lowered) {
if marker_end <= clause_start {
request.get(marker_end..clause_start)
} else {
request.get(marker_end..)
}
} else if cue_is_destination {
let action_end = first_action_cue_end(&toks)?;
(action_end <= clause_start).then(|| request.get(action_end..clause_start))?
} else {
None
};
let content = clean_content(content_span?)?;
Some((target, content))
}
#[must_use]
pub fn compose_edit_request(request: &str) -> Option<(String, String, String)> {
let toks = tokens(request);
let action_cues = bare_surfaces(seed::ROLE_FILE_EDIT_ACTION_CUE);
let new_leads = bare_surfaces(seed::ROLE_FILE_EDIT_NEW_LEAD_CUE);
let target_cues = bare_surfaces(seed::ROLE_FILE_EDIT_TARGET_CUE);
let is_target_cue = |index: usize| target_cues.contains(&clean_cue_token(toks[index].text));
let (file_index, target) = toks.iter().enumerate().find_map(|(index, token)| {
let cleaned = clean_path_token(token.text);
let looks_like_file = cleaned.contains('.') && !cleaned.contains("://");
if !looks_like_file || !safe_relative_path(cleaned) {
return None;
}
let prev_is_cue = index.checked_sub(1).is_some_and(&is_target_cue);
let next_is_cue = (index + 1 < toks.len()) && is_target_cue(index + 1);
(prev_is_cue || next_is_cue).then(|| (index, cleaned.to_owned()))
})?;
let mut clause_start_index = file_index;
while clause_start_index > 0 && is_target_cue(clause_start_index - 1) {
clause_start_index -= 1;
}
let file_clause_start = toks[clause_start_index].start;
let action_end = toks
.iter()
.find(|token| action_cues.contains(&clean_cue_token(token.text)))
.map(|token| token.end)?;
let new_lead = toks.iter().find(|token| {
token.start >= action_end && new_leads.contains(&clean_cue_token(token.text))
})?;
if file_clause_start >= action_end && file_clause_start < new_lead.start {
return None;
}
let old_span = request.get(action_end..new_lead.start)?;
let new_end = if file_clause_start > new_lead.end {
file_clause_start
} else {
request.len()
};
let new_span = request.get(new_lead.end..new_end)?;
let old = clean_content(old_span)?;
let new = clean_content(new_span)?;
Some((target, old, new))
}
struct Token<'a> {
text: &'a str,
start: usize,
end: usize,
}
fn tokens(request: &str) -> Vec<Token<'_>> {
let mut cursor = 0;
request
.split_whitespace()
.map(|word| {
let start = request[cursor..]
.find(word)
.map_or(cursor, |offset| cursor + offset);
let end = start + word.len();
cursor = end;
Token {
text: word,
start,
end,
}
})
.collect()
}
fn bare_surfaces(role: &str) -> Vec<String> {
seed::lexicon()
.role_word_forms(role)
.iter()
.filter(|form| form.slot() == Slot::Bare)
.map(|form| form.text.to_lowercase())
.collect()
}
fn clean_path_token(word: &str) -> &str {
word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ':' | ';'))
.trim_end_matches(['.', '!', '?'])
}
fn clean_cue_token(word: &str) -> String {
word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ':' | ';' | '.' | '!' | '?'))
.to_lowercase()
}
fn first_content_lead_end(lowered: &str) -> Option<(usize, usize)> {
let markers: Vec<String> = seed::lexicon()
.role_word_forms(seed::ROLE_FILE_WRITE_CONTENT_LEAD)
.iter()
.filter(|form| form.slot() == Slot::Prefix)
.map(|form| form.before_slot().trim().to_lowercase())
.filter(|marker| !marker.is_empty())
.collect();
let mut best: Option<(usize, usize)> = None;
for marker in &markers {
let mut from = 0;
while let Some(relative) = lowered[from..].find(marker.as_str()) {
let start = from + relative;
let end = start + marker.len();
let cjk = !marker.contains(' ') && !marker.is_ascii();
let before_ok = cjk
|| start == 0
|| lowered[..start]
.chars()
.next_back()
.is_some_and(char::is_whitespace);
let after_ok = cjk
|| end == lowered.len()
|| lowered[end..]
.chars()
.next()
.is_some_and(|c| c.is_whitespace() || c.is_ascii_punctuation());
if before_ok && after_ok {
if best.is_none_or(|(best_start, _)| start < best_start) {
best = Some((start, end));
}
break;
}
from = end;
}
}
best
}
fn first_action_cue_end(toks: &[Token<'_>]) -> Option<usize> {
let actions = bare_surfaces(seed::ROLE_FILE_WRITE_ACTION_CUE);
toks.iter()
.find(|token| actions.contains(&clean_cue_token(token.text)))
.map(|token| token.end)
}
fn clean_content(raw: &str) -> Option<String> {
let led = raw.trim().trim_start_matches([':', '-', '—', '–']).trim();
let unquoted = led.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '.' | '。'));
let result = unquoted.trim();
(!result.is_empty()).then(|| result.to_owned())
}
fn safe_relative_path(path: &str) -> bool {
!path.starts_with('/')
&& !path.starts_with('-')
&& !path.split('/').any(|part| part == ".." || part.is_empty())
&& path
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
}
const fn capability_slug(capability: Capability) -> &'static str {
match capability {
Capability::Search => "Search",
Capability::Fetch => "Fetch",
Capability::Read => "Read",
Capability::Write => "Write",
Capability::Edit => "Edit",
Capability::Run => "Run",
}
}
fn language(request: &str) -> &'static str {
if request
.chars()
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c))
{
"ru"
} else {
"en"
}
}
fn escape(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
fn field(out: &mut String, name: &str, value: &str) {
let _ = writeln!(out, " {name} \"{}\"", escape(value));
}
fn field_nested(out: &mut String, name: &str, value: &str) {
let _ = writeln!(out, " {name} \"{}\"", escape(value));
}