Skip to main content

xtalforge_agent/
session.rs

1//! Stateful session (Task 8.1.2).
2//!
3//! A session owns documents, runs verbs by name with JSON arguments and
4//! returns typed answers. Every mutation goes through
5//! [`xtalforge_commands::Document`] with [`xtalforge_commands::Origin::Agent`], so the history and
6//! the change bursts are the same objects a user front end sees.
7
8use std::collections::BTreeMap;
9use std::path::Path;
10
11use serde_json::Value;
12use xtalforge_commands::Document;
13use xtalforge_core::{Limits, Profile, Structure};
14
15use crate::Error;
16use crate::answers::{Answer, DocumentSummary};
17
18/// Options a session starts with.
19#[derive(Debug, Clone, PartialEq)]
20pub struct SessionOptions {
21    /// Interactive size limits applied to every verb.
22    pub limits: Limits,
23}
24
25impl Default for SessionOptions {
26    fn default() -> Self {
27        Self {
28            limits: Limits::new(Profile::Standard),
29        }
30    }
31}
32
33#[derive(Debug)]
34pub(crate) struct Entry {
35    document: Document,
36    source: Option<String>,
37}
38
39/// A stateful agent session.
40#[derive(Debug)]
41pub struct Session {
42    documents: BTreeMap<String, Entry>,
43    active: Option<String>,
44    options: SessionOptions,
45}
46
47impl Default for Session {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl Session {
54    /// Creates an empty session with the interactive limits.
55    #[must_use]
56    pub fn new() -> Self {
57        Self::with_options(SessionOptions::default())
58    }
59
60    /// Creates an empty session with explicit options.
61    #[must_use]
62    pub fn with_options(options: SessionOptions) -> Self {
63        Self {
64            documents: BTreeMap::new(),
65            active: None,
66            options,
67        }
68    }
69
70    /// Options the session runs under.
71    #[must_use]
72    pub fn options(&self) -> &SessionOptions {
73        &self.options
74    }
75
76    /// Name of the active document.
77    #[must_use]
78    pub fn active_name(&self) -> Option<&str> {
79        self.active.as_deref()
80    }
81
82    /// The structure of one document, or of the active one.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`Error::UnknownDocument`] or [`Error::NoDocument`].
87    pub fn structure(&self, name: Option<&str>) -> Result<&Structure, Error> {
88        Ok(self.document(name)?.structure())
89    }
90
91    /// The active document, when there is one.
92    #[must_use]
93    pub fn active(&self) -> Option<&Document> {
94        self.active_name()
95            .and_then(|name| self.documents.get(name))
96            .map(|entry| &entry.document)
97    }
98
99    /// Whether a document is open.
100    #[must_use]
101    pub fn contains(&self, name: &str) -> bool {
102        self.documents.contains_key(name)
103    }
104
105    /// Number of open documents.
106    #[must_use]
107    pub fn len(&self) -> usize {
108        self.documents.len()
109    }
110
111    /// Whether no document is open.
112    #[must_use]
113    pub fn is_empty(&self) -> bool {
114        self.documents.is_empty()
115    }
116
117    /// Resolves a document name, falling back to the active document.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`Error::UnknownDocument`] or [`Error::NoDocument`].
122    pub(crate) fn resolve(&self, name: Option<&str>) -> Result<String, Error> {
123        match name {
124            Some(name) => {
125                if self.documents.contains_key(name) {
126                    Ok(name.to_owned())
127                } else {
128                    Err(Error::UnknownDocument(name.to_owned()))
129                }
130            }
131            None => self
132                .active_name()
133                .map(str::to_owned)
134                .ok_or(Error::NoDocument),
135        }
136    }
137
138    pub(crate) fn document(&self, name: Option<&str>) -> Result<&Document, Error> {
139        let name = self.resolve(name)?;
140        self.documents
141            .get(&name)
142            .map(|entry| &entry.document)
143            .ok_or(Error::UnknownDocument(name))
144    }
145
146    pub(crate) fn document_mut(&mut self, name: Option<&str>) -> Result<&mut Document, Error> {
147        let name = self.resolve(name)?;
148        self.documents
149            .get_mut(&name)
150            .map(|entry| &mut entry.document)
151            .ok_or(Error::UnknownDocument(name))
152    }
153
154    pub(crate) fn source(&self, name: &str) -> Option<&str> {
155        self.documents
156            .get(name)
157            .and_then(|entry| entry.source.as_deref())
158    }
159
160    /// Adds a document; an existing name is replaced and made active.
161    pub(crate) fn insert(
162        &mut self,
163        name: impl Into<String>,
164        structure: Structure,
165        source: Option<String>,
166    ) -> String {
167        let name = name.into();
168        self.documents.insert(
169            name.clone(),
170            Entry {
171                document: Document::new(structure),
172                source,
173            },
174        );
175        self.active = Some(name.clone());
176        name
177    }
178
179    /// Suggests a free document name from a path or a fallback.
180    pub(crate) fn available_name(&self, path: Option<&str>, fallback: &str) -> String {
181        let stem = path
182            .and_then(|path| Path::new(path).file_stem())
183            .and_then(|stem| stem.to_str())
184            .filter(|stem| !stem.is_empty())
185            .unwrap_or(fallback);
186        if !self.documents.contains_key(stem) {
187            return stem.to_owned();
188        }
189        let mut index = 2;
190        loop {
191            let candidate = format!("{stem}-{index}");
192            if !self.documents.contains_key(&candidate) {
193                return candidate;
194            }
195            index += 1;
196        }
197    }
198
199    pub(crate) fn remove(&mut self, name: &str) -> bool {
200        let removed = self.documents.remove(name).is_some();
201        if removed && self.active.as_deref() == Some(name) {
202            self.active = self.documents.keys().next().cloned();
203        }
204        removed
205    }
206
207    /// Summaries of the open documents, in name order.
208    #[must_use]
209    pub fn summaries(&self) -> Vec<DocumentSummary> {
210        self.documents
211            .iter()
212            .filter_map(|(name, entry)| summary(name, entry).ok())
213            .collect()
214    }
215
216    /// The `documents` answer.
217    #[must_use]
218    pub fn documents_answer(&self) -> Answer {
219        Answer::Documents {
220            documents: self.summaries(),
221            active: self.active.clone(),
222        }
223    }
224
225    /// Runs one verb.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`Error::UnknownVerb`] for a name outside the catalogue, the
230    /// argument errors of the verb and the errors of the operation it runs.
231    pub fn run(&mut self, verb: &str, arguments: &Value) -> Result<Answer, Error> {
232        if crate::verbs::lookup(verb).is_none() {
233            return Err(Error::UnknownVerb(verb.to_owned()));
234        }
235        crate::run::run(self, verb, arguments)
236    }
237}
238
239pub(crate) fn summary(name: &str, entry: &Entry) -> Result<DocumentSummary, Error> {
240    let structure = entry.document.structure();
241    let summary = structure.summary()?;
242    Ok(DocumentSummary {
243        name: name.to_owned(),
244        title: summary.name,
245        formula: summary.formula,
246        n_sites: summary.n_sites,
247        volume: summary.volume,
248        density: summary.density,
249        source: entry.source.clone(),
250        dirty: entry.document.dirty(),
251    })
252}
253
254pub(crate) fn entry_summary(
255    name: &str,
256    document: &Document,
257    source: Option<&str>,
258) -> Result<DocumentSummary, Error> {
259    let summary = document.structure().summary()?;
260    Ok(DocumentSummary {
261        name: name.to_owned(),
262        title: summary.name,
263        formula: summary.formula,
264        n_sites: summary.n_sites,
265        volume: summary.volume,
266        density: summary.density,
267        source: source.map(str::to_owned),
268        dirty: document.dirty(),
269    })
270}