Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions crates/procnote-core/src/event/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ pub fn read_events(path: &Path) -> Result<Vec<Event>, EventLogError> {
mod tests {
use super::*;
use crate::event::types::{CompletionStatus, ExecutionId};
use crate::template::types::StepContent;
use chrono::Utc;
use uuid::Uuid;

Expand Down Expand Up @@ -210,10 +211,10 @@ mod tests {
at: now,
execution_id: id,
heading: "New Step".to_string(),
description: Some("Added during execution".to_string()),
content: vec![StepContent::Prose {
text: "Added during execution".to_string(),
}],
after_step: Some("Preconditions".to_string()),
checkboxes: vec![],
inputs: vec![],
},
Event::StepStarted {
at: now,
Expand Down
13 changes: 4 additions & 9 deletions crates/procnote-core/src/event/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::template::types::InputDefinition;
use crate::template::types::StepContent;

/// Unique identifier for an execution.
pub type ExecutionId = Uuid;
Expand Down Expand Up @@ -48,17 +48,12 @@ pub enum Event {
at: DateTime<Utc>,
execution_id: ExecutionId,
heading: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
/// Ordered content items from the template (prose, checkboxes, input blocks).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
content: Vec<StepContent>,
/// Insert after this step heading. `None` means append at end.
#[serde(skip_serializing_if = "Option::is_none")]
after_step: Option<String>,
/// Checkbox texts to initialize in this step (from template).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
checkboxes: Vec<String>,
/// Input definitions for this step (from template).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
inputs: Vec<InputDefinition>,
},
StepStarted {
at: DateTime<Utc>,
Expand Down
108 changes: 42 additions & 66 deletions crates/procnote-core/src/execution/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use chrono::Utc;
use uuid::Uuid;

use crate::event::types::{CompletionStatus, Event, ExecutionId, Revertibility};
use crate::template::types::{InputDefinition, ProcedureTemplate, StepContent};
use crate::template::types::{ProcedureTemplate, StepContent};

/// Errors that can occur during execution state transitions.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
Expand Down Expand Up @@ -59,12 +59,10 @@ pub enum StepStatus {
#[derive(Debug, Clone)]
pub struct StepState {
pub heading: String,
pub description: Option<String>,
pub status: StepStatus,
/// Checkbox text -> checked state. Insertion order preserved by `step_order`.
pub checkboxes: Vec<(String, bool)>,
/// Input definitions for this step (from template or `StepAdded` event).
pub input_definitions: Vec<InputDefinition>,
/// Ordered content items from the template (prose, checkboxes, input blocks).
/// Checkbox `checked` state is mutated in-place.
pub content: Vec<StepContent>,
/// Recorded input values keyed by label.
pub inputs: HashMap<String, RecordedInput>,
pub notes: Vec<String>,
Expand Down Expand Up @@ -176,10 +174,8 @@ impl ExecutionState {
}
Event::StepAdded {
heading,
description,
content,
after_step,
checkboxes,
inputs,
..
} => {
self.require_active()?;
Expand All @@ -188,10 +184,8 @@ impl ExecutionState {
}
let step_state = StepState {
heading: heading.clone(),
description: description.clone(),
status: StepStatus::Pending,
checkboxes: checkboxes.iter().map(|t| (t.clone(), false)).collect(),
input_definitions: inputs.clone(),
content: content.clone(),
inputs: HashMap::new(),
notes: Vec::new(),
};
Expand Down Expand Up @@ -255,11 +249,25 @@ impl ExecutionState {
} => {
self.require_active()?;
let step = self.get_step_mut(step_heading)?;
if let Some(entry) = step.checkboxes.iter_mut().find(|(t, _)| t == text) {
entry.1 = *checked;
} else {
// Find matching checkbox in content and update in-place.
let found = step.content.iter_mut().any(|item| {
if let StepContent::Checkbox {
text: t,
checked: c,
} = item
&& t == text
{
*c = *checked;
return true;
}
false
});
if !found {
// Checkbox not from template — add dynamically.
step.checkboxes.push((text.clone(), *checked));
step.content.push(StepContent::Checkbox {
text: text.clone(),
checked: *checked,
});
}
}
Event::InputRecorded {
Expand Down Expand Up @@ -362,37 +370,14 @@ impl ExecutionState {
self.apply(&named)?;
events.push(named);

// Add steps from the template, including checkboxes and input definitions.
// Add steps from the template, preserving content order.
for step in &template.steps {
let mut checkboxes = Vec::new();
let mut input_defs = Vec::new();
let mut prose_parts = Vec::new();
for content in &step.content {
match content {
StepContent::Checkbox { text, .. } => {
checkboxes.push(text.clone());
}
StepContent::InputBlock { inputs } => {
input_defs.extend(inputs.iter().cloned());
}
StepContent::Prose { text } => {
prose_parts.push(text.clone());
}
}
}
let description = if prose_parts.is_empty() {
None
} else {
Some(prose_parts.join("\n\n"))
};
let step_added = Event::StepAdded {
at: now,
execution_id,
heading: step.heading.clone(),
description,
content: step.content.clone(),
after_step: None,
checkboxes,
inputs: input_defs,
};
self.apply(&step_added)?;
events.push(step_added);
Expand All @@ -419,18 +404,16 @@ impl ExecutionState {
pub fn add_step(
&mut self,
heading: &str,
description: Option<&str>,
content: Vec<StepContent>,
after_step: Option<&str>,
) -> Result<Event, ExecutionError> {
self.require_active()?;
let event = Event::StepAdded {
at: Utc::now(),
execution_id: self.require_execution_id()?,
heading: heading.to_string(),
description: description.map(std::string::ToString::to_string),
content,
after_step: after_step.map(std::string::ToString::to_string),
checkboxes: Vec::new(),
inputs: Vec::new(),
};
self.apply(&event)?;
Ok(event)
Expand Down Expand Up @@ -670,7 +653,7 @@ impl Default for ExecutionState {
#[expect(clippy::unwrap_used, reason = "unwrap is acceptable in tests")]
mod tests {
use super::*;
use crate::template::types::{ProcedureMetadata, ProcedureTemplate, Step};
use crate::template::types::{ProcedureMetadata, ProcedureTemplate, Step, StepContent};

fn sample_template() -> ProcedureTemplate {
ProcedureTemplate {
Expand Down Expand Up @@ -794,12 +777,9 @@ mod tests {
StepStatus::Completed
);
assert_eq!(state.steps["Postconditions"].status, StepStatus::Skipped);
assert!(
state.steps["Preconditions"]
.checkboxes
.iter()
.any(|(t, c)| t == "Check 1" && *c)
);
assert!(state.steps["Preconditions"].content.iter().any(|item| {
matches!(item, StepContent::Checkbox { text, checked } if text == "Check 1" && *checked)
}));
assert_eq!(
state.steps["Step 1: Power On"].inputs["Current"].value,
"120"
Expand All @@ -813,12 +793,9 @@ mod tests {
ExecutionStatus::Finished(CompletionStatus::Pass)
);
assert_eq!(replayed.step_order.len(), 3);
assert!(
replayed.steps["Preconditions"]
.checkboxes
.iter()
.any(|(t, c)| t == "Check 1" && *c)
);
assert!(replayed.steps["Preconditions"].content.iter().any(|item| {
matches!(item, StepContent::Checkbox { text, checked } if text == "Check 1" && *checked)
}));
}

#[test]
Expand All @@ -831,7 +808,9 @@ mod tests {
state
.add_step(
"Step 1.5: Verification",
Some("Extra verification step"),
vec![StepContent::Prose {
text: "Extra verification step".to_string(),
}],
Some("Step 1: Power On"),
)
.unwrap();
Expand Down Expand Up @@ -951,7 +930,7 @@ mod tests {
let mut state = ExecutionState::new();
state.start(&template).unwrap();

let result = state.add_step("Preconditions", None, None);
let result = state.add_step("Preconditions", vec![], None);
assert_eq!(
result.unwrap_err(),
ExecutionError::DuplicateStepHeading("Preconditions".to_string())
Expand Down Expand Up @@ -1068,12 +1047,9 @@ mod tests {
let state = ExecutionState::from_events(&events).unwrap();
// The checkbox was dynamically added by the toggle; reverting removes it entirely
// since it was not in the template.
assert!(
!state.steps["Preconditions"]
.checkboxes
.iter()
.any(|(t, _)| t == "Check A")
);
assert!(!state.steps["Preconditions"].content.iter().any(|item| {
matches!(item, StepContent::Checkbox { text, .. } if text == "Check A")
}));
}

#[test]
Expand Down
Loading