Skip to main content

headless_lms_mcp/engine/course_structure/
paths.rs

1//! Page URL paths: the ones a structure call derives at its end, and links to the old ones.
2
3use std::collections::{HashMap, HashSet};
4
5use headless_lms_models::chapters;
6use headless_lms_models::pages::normalize_url_path_for_storage;
7use headless_lms_models::url_redirections::CreatedRedirect;
8use headless_lms_utils::strings::slugify;
9use serde::Serialize;
10use uuid::Uuid;
11
12use super::model::{CourseStructure, StructurePage};
13use crate::engine::error::{EditError, EditErrorCode, Warning, WarningCode};
14use crate::engine::{PageRef, blocks};
15
16/// A link in course content that points at a page's old path.
17#[derive(Debug, Clone, PartialEq, Serialize)]
18pub struct LinkToOldPath {
19    pub page_id: Uuid,
20    pub block_id: Uuid,
21    pub old_path: String,
22    pub new_path: String,
23    /// The page now at the old path, which wins over the redirect from it, so the link opens it.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub now_opens_page: Option<Uuid>,
26}
27
28/// Links in `pages` whose target ends in `/<course slug><old path>` of a page in `changed`.
29/// `structure` has the paths after the change. The links are reported, not rewritten: most keep
30/// working through the redirects, but one whose old path another page now has opens that page.
31pub(super) fn find_links_to_old_paths(
32    pages: &[PageRef],
33    course_slug: &str,
34    changed: &[CreatedRedirect],
35    structure: &CourseStructure,
36) -> Vec<LinkToOldPath> {
37    if changed.is_empty() {
38        return Vec::new();
39    }
40    let by_old_path: HashMap<&str, &CreatedRedirect> = changed
41        .iter()
42        .map(|change| (change.old_url_path.trim_end_matches('/'), change))
43        .collect();
44    let path_owners: HashMap<&str, Uuid> = structure
45        .pages()
46        .map(|(_, page)| (page.url_path.as_str(), page.id))
47        .collect();
48    let course_prefix = format!("/{course_slug}");
49    let mut found = Vec::new();
50    let mut is_found: HashSet<(Uuid, &str)> = HashSet::new();
51    for page in pages {
52        blocks::walk(page.blocks, &mut |block, _| {
53            blocks::for_each_string(&block.attributes, &mut |text| {
54                for (at, _) in text.match_indices(course_prefix.as_str()) {
55                    let after = &text[at + course_prefix.len()..];
56                    let path = &after[..after.find(['"', '\'', '#', '?']).unwrap_or(after.len())];
57                    if let Some(change) = by_old_path.get(path)
58                        && is_found.insert((block.client_id, change.old_url_path.as_str()))
59                    {
60                        found.push(LinkToOldPath {
61                            page_id: page.page_id,
62                            block_id: block.client_id,
63                            old_path: change.old_url_path.clone(),
64                            new_path: change.new_url_path.clone(),
65                            now_opens_page: path_owners
66                                .get(change.old_url_path.as_str())
67                                .filter(|owner| **owner != change.page_id)
68                                .copied(),
69                        });
70                    }
71                }
72            });
73        });
74    }
75    found
76}
77
78/// The paths a call's ops asked for, applied when the call ends.
79#[derive(Debug, Default)]
80pub(super) struct PathRequests {
81    /// `url_path`s set by ops, as given.
82    explicit: HashMap<Uuid, String>,
83    /// The path segment of each page copy, `<source segment>-copy`; it gets a suffix on collision
84    /// like other derived paths.
85    copy_segments: HashMap<Uuid, String>,
86}
87
88impl PathRequests {
89    /// Requests `path` for the page `page_id`.
90    pub(super) fn set_explicit(&mut self, page_id: Uuid, path: String) {
91        self.explicit.insert(page_id, path);
92    }
93
94    /// Derives the path segment of `copy_id`, a copy of `source`.
95    pub(super) fn add_copy(&mut self, copy_id: Uuid, source: &StructurePage) {
96        let segment =
97            last_segment(&source.url_path).unwrap_or_else(|| title_segment(&source.title));
98        self.copy_segments
99            .insert(copy_id, format!("{segment}-copy"));
100    }
101
102    /// The last path segment a page gets when no op set its path: the one it had, a copy's
103    /// `<source>-copy`, or its slugified title.
104    fn derived_segment(
105        &self,
106        page: &StructurePage,
107        before_paths: &HashMap<Uuid, String>,
108    ) -> String {
109        before_paths
110            .get(&page.id)
111            .and_then(|old| last_segment(old))
112            .or_else(|| self.copy_segments.get(&page.id).cloned())
113            .unwrap_or_else(|| title_segment(&page.title))
114    }
115}
116
117/// A page's path before collisions are resolved.
118struct PlannedPath {
119    page_id: Uuid,
120    path: String,
121    /// Set by an op or fixed by the page's position; collisions with it are errors.
122    is_explicit: bool,
123}
124
125/// Gives every page of `doc` its final path: chapter front pages `/chapter-N`, other chapter
126/// pages `/chapter-N/<segment>`, top-level pages their own path. Derived paths get a suffix on
127/// collision, with a warning; explicit duplicates are errors.
128pub(super) fn assign_paths(
129    doc: &mut CourseStructure,
130    requests: &PathRequests,
131    before_paths: &HashMap<Uuid, String>,
132    front_page: Option<Uuid>,
133) -> Result<Vec<Warning>, EditError> {
134    let mut planned: Vec<PlannedPath> = Vec::new();
135    let mut plan = |page_id, path, is_explicit| {
136        planned.push(PlannedPath {
137            page_id,
138            path,
139            is_explicit,
140        })
141    };
142    for page in &doc.pages {
143        if Some(page.id) == front_page {
144            plan(page.id, "/".to_string(), true);
145            continue;
146        }
147        match requests.explicit.get(&page.id) {
148            Some(raw) => plan(page.id, normalize_top_level_path(raw)?, true),
149            None => {
150                let path = match before_paths.get(&page.id) {
151                    Some(old) if !old.starts_with("/chapter-") && !old.is_empty() => old.clone(),
152                    _ => format!("/{}", requests.derived_segment(page, before_paths)),
153                };
154                plan(page.id, path, false);
155            }
156        }
157    }
158    for chapter in doc.chapters() {
159        let prefix = chapters::front_page_path(chapter.chapter_number);
160        for (index, page) in chapter.pages.iter().enumerate() {
161            if index == 0 {
162                if let Some(raw) = requests.explicit.get(&page.id) {
163                    let accepted = raw.trim_end_matches('/') == prefix || raw.trim().is_empty();
164                    if !accepted {
165                        return Err(EditError::new(
166                            EditErrorCode::InvalidUrlPath,
167                            format!(
168                                "page {} is the first page of chapter {}, so its path is always {prefix}",
169                                page.id, chapter.chapter_number
170                            ),
171                        ));
172                    }
173                }
174                plan(page.id, prefix.clone(), true);
175                continue;
176            }
177            let (segment, is_explicit) = match requests.explicit.get(&page.id) {
178                Some(raw) => (chapter_page_segment(raw, &prefix)?, true),
179                None => (requests.derived_segment(page, before_paths), false),
180            };
181            plan(page.id, format!("{prefix}/{segment}"), is_explicit);
182        }
183    }
184
185    let mut taken: HashMap<String, Uuid> = HashMap::new();
186    // Paths that stay as they were count as taken too, so an explicit path never silently
187    // renames another page.
188    for PlannedPath {
189        page_id: id,
190        path,
191        is_explicit,
192    } in &planned
193    {
194        let is_kept = before_paths.get(id).is_some_and(|old| old == path);
195        if (*is_explicit || is_kept)
196            && let Some(other) = taken.insert(path.clone(), *id)
197        {
198            return Err(EditError::new(
199                EditErrorCode::DuplicatePath,
200                format!("pages {other} and {id} would both have the path {path}"),
201            )
202            .with_fix("give one of them another url_path"));
203        }
204    }
205    let mut warnings = Vec::new();
206    let mut final_paths: HashMap<Uuid, String> = HashMap::new();
207    for PlannedPath {
208        page_id: id,
209        path,
210        is_explicit,
211    } in planned
212    {
213        if is_explicit || taken.get(&path) == Some(&id) {
214            final_paths.insert(id, path);
215            continue;
216        }
217        let mut candidate = path.clone();
218        let mut suffix = 2;
219        while taken.get(&candidate).is_some_and(|holder| *holder != id) {
220            candidate = format!("{path}-{suffix}");
221            suffix += 1;
222        }
223        if candidate != path && before_paths.get(&id).is_none_or(|old| *old != candidate) {
224            warnings.push(Warning::new(
225                WarningCode::UrlPathAdjusted,
226                format!("page {id} got the path {candidate} because {path} was taken"),
227            ));
228        }
229        taken.insert(candidate.clone(), id);
230        final_paths.insert(id, candidate);
231    }
232    for page in doc.pages_mut() {
233        if let Some(path) = final_paths.remove(&page.id) {
234            page.url_path = path;
235        }
236    }
237    Ok(warnings)
238}
239
240/// Old and new paths of the pages of `doc` whose path differs from `before_paths`.
241pub(super) fn path_changes(
242    doc: &CourseStructure,
243    before_paths: &HashMap<Uuid, String>,
244) -> Vec<CreatedRedirect> {
245    doc.pages()
246        .filter_map(|(_, page)| {
247            let old = before_paths.get(&page.id)?;
248            (*old != page.url_path).then(|| CreatedRedirect {
249                page_id: page.id,
250                old_url_path: old.clone(),
251                new_url_path: page.url_path.clone(),
252            })
253        })
254        .collect()
255}
256
257fn is_chapter_segment(segment: &str) -> bool {
258    segment
259        .strip_prefix("chapter-")
260        .is_some_and(|n| n.parse::<u32>().is_ok())
261}
262
263fn last_segment(path: &str) -> Option<String> {
264    path.rsplit('/')
265        .next()
266        .filter(|s| !s.is_empty() && !is_chapter_segment(s))
267        .map(str::to_string)
268}
269
270/// The path segment a page with `title` gets.
271fn title_segment(title: &str) -> String {
272    let slug = slugify(title);
273    if slug.is_empty() {
274        "page".to_string()
275    } else {
276        slug
277    }
278}
279
280/// `raw` as one path segment, in the form storage keeps ([`normalize_url_path_for_storage`]), so
281/// an agent can set any path a teacher can.
282fn normalize_segment(raw: &str) -> Result<String, EditError> {
283    let segment = normalize_url_path_for_storage(raw.trim());
284    // A dot segment would make the page unreachable: URL resolution removes it.
285    if segment.is_empty() || segment == "." || segment == ".." || segment.contains('/') {
286        return Err(EditError::new(
287            EditErrorCode::InvalidUrlPath,
288            format!("\"{raw}\" isn't a valid path segment; use letters, digits and dashes"),
289        )
290        .with_fix(title_segment(raw)));
291    }
292    Ok(segment)
293}
294
295fn normalize_top_level_path(raw: &str) -> Result<String, EditError> {
296    let trimmed = raw.trim().trim_end_matches('/');
297    let Some(rest) = trimmed.strip_prefix('/') else {
298        return Err(EditError::new(
299            EditErrorCode::InvalidUrlPath,
300            format!("\"{raw}\" must start with /"),
301        )
302        .with_fix(format!("/{}", raw.trim())));
303    };
304    let segments = rest
305        .split('/')
306        .map(normalize_segment)
307        .collect::<Result<Vec<_>, _>>()?;
308    if segments
309        .first()
310        .is_some_and(|first| is_chapter_segment(first))
311    {
312        return Err(EditError::new(
313            EditErrorCode::InvalidUrlPath,
314            "paths starting with /chapter-N belong to chapter pages",
315        ));
316    }
317    Ok(format!("/{}", segments.join("/")))
318}
319
320/// A chapter page's explicit path: its last segment alone, or the whole path under some `/chapter-N`.
321fn chapter_page_segment(raw: &str, prefix: &str) -> Result<String, EditError> {
322    let trimmed = raw.trim().trim_end_matches('/');
323    let candidate = match trimmed.strip_prefix('/') {
324        None => trimmed,
325        Some(rest) => match rest.split_once('/') {
326            Some((first, last)) if is_chapter_segment(first) && !last.contains('/') => last,
327            _ => {
328                let segment = title_segment(trimmed);
329                return Err(EditError::new(
330                    EditErrorCode::InvalidUrlPath,
331                    format!("chapter pages live under {prefix}/; \"{raw}\" doesn't fit"),
332                )
333                .with_fix(format!(
334                    "give only the last part, e.g. \"{segment}\", which becomes {prefix}/{segment}"
335                )));
336            }
337        },
338    };
339    normalize_segment(candidate)
340}