Skip to main content

sharepoint_cli/graph/
sites.rs

1//! Site discovery: followed sites (no query) and keyword search (with query).
2//!
3//! Resolution flow used by command code:
4//!  1. SiteRef::Url      → `/sites/{hostname}:/{path}` lookup, returns Site.
5//!  2. SiteRef::Name     → check profile aliases first, then `/sites?search=`.
6//!  3. SiteRef::Default  → use ResolvedConfig.default_site, recurse.
7
8use std::collections::BTreeMap;
9use std::fmt::Write as _;
10
11use serde::Deserialize;
12
13use super::GraphClient;
14use crate::error::{CliError, Result};
15use crate::reference::SiteRef;
16
17#[derive(Debug, Clone, Deserialize)]
18pub(crate) struct Site {
19    pub(crate) id: String,
20    #[serde(rename = "displayName", default)]
21    pub(crate) display_name: String,
22    #[serde(rename = "webUrl")]
23    pub(crate) web_url: String,
24}
25
26#[derive(Debug, Clone)]
27pub(crate) enum SiteListSource {
28    Followed,
29    Search,
30}
31
32pub(crate) struct SiteListResult {
33    pub(crate) items: Vec<Site>,
34    /// The raw `@odata.nextLink` URL returned by Graph, if there are more results.
35    pub(crate) next_url: Option<String>,
36    /// The URL that was actually fetched. Used by callers that need to encode a
37    /// mid-page cursor.
38    pub(crate) fetched_url: String,
39    pub(crate) source: SiteListSource,
40}
41
42/// Without `query`: returns the user's followed sites.
43/// With `query`: keyword search across the tenant.
44/// `page_url` is a raw Graph URL for continuation; when `None` the first page
45/// is fetched using the default path derived from `query`.
46pub(crate) async fn list(
47    graph: &GraphClient,
48    query: Option<&str>,
49    page_url: Option<&str>,
50) -> Result<SiteListResult> {
51    let (path, source) = match (query, page_url) {
52        (_, Some(url)) => {
53            let source = source_for_path(url);
54            (url.to_string(), source)
55        }
56        (None, None) => ("/me/followedSites".to_string(), SiteListSource::Followed),
57        (Some(q), None) => (
58            format!("/sites?search={}", urlencoding(q)),
59            SiteListSource::Search,
60        ),
61    };
62    // Resolve to a full absolute URL so the cursor stored in `fetched_url`
63    // is always a complete URL (required by the host-validation check on decode).
64    let absolute_url = graph.url(&path).await;
65    let page: super::PagedResponse<Site> = graph.get_json(&absolute_url).await?;
66    Ok(SiteListResult {
67        items: page.value,
68        next_url: page.next_link,
69        fetched_url: absolute_url,
70        source,
71    })
72}
73
74/// Resolve a site by URL (`/sites/{hostname}:/{path}`).
75pub(crate) async fn get_by_url(graph: &GraphClient, url: &str) -> Result<Site> {
76    let parsed = url::Url::parse(url)
77        .map_err(|e| CliError::Input(format!("invalid site URL '{url}': {e}")))?;
78    let host = parsed
79        .host_str()
80        .ok_or_else(|| CliError::Input(format!("site URL has no host: {url}")))?;
81    let path = parsed.path().trim_start_matches('/');
82    let api_path = format!("/sites/{host}:/{path}");
83    graph.get_json::<Site>(&api_path).await
84}
85
86/// Resolve a site by name (alias map first, then `/sites?search=`).
87pub(crate) async fn resolve(
88    graph: &GraphClient,
89    site_ref: &SiteRef,
90    aliases: &BTreeMap<String, String>,
91    default_site: Option<&str>,
92) -> Result<Site> {
93    match site_ref {
94        SiteRef::Url(u) => get_by_url(graph, u).await,
95        SiteRef::Default => {
96            let raw = default_site.ok_or_else(|| {
97                CliError::Input(
98                    "this reference uses the default site but none is configured".into(),
99                )
100            })?;
101            let nested = if raw.starts_with("http://") || raw.starts_with("https://") {
102                SiteRef::Url(raw.to_string())
103            } else {
104                SiteRef::Name(raw.to_string())
105            };
106            Box::pin(resolve(graph, &nested, aliases, None)).await
107        }
108        SiteRef::Name(name) => {
109            let lower = name.to_ascii_lowercase();
110            for (k, v) in aliases {
111                if k.to_ascii_lowercase() == lower {
112                    return get_by_url(graph, v).await;
113                }
114            }
115            let path = format!("/sites?search={}", urlencoding(name));
116            let page: super::PagedResponse<Site> = graph.get_json(&path).await?;
117            let exact = page
118                .value
119                .iter()
120                .find(|s| s.display_name.eq_ignore_ascii_case(name))
121                .cloned();
122            exact
123                .or_else(|| page.value.into_iter().next())
124                .ok_or_else(|| CliError::NotFound(format!("site '{name}' not found")))
125        }
126    }
127}
128
129fn urlencoding(input: &str) -> String {
130    let mut out = String::with_capacity(input.len());
131    for b in input.bytes() {
132        match b {
133            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
134                out.push(b as char)
135            }
136            _ => write!(out, "%{b:02X}").unwrap(),
137        }
138    }
139    out
140}
141
142/// Derive the list source from a decoded page-token path.
143fn source_for_path(path: &str) -> SiteListSource {
144    if path.contains("/me/followedSites") {
145        SiteListSource::Followed
146    } else {
147        SiteListSource::Search
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn url_encoding_handles_spaces() {
157        assert_eq!(urlencoding("Marketing Plan"), "Marketing%20Plan");
158    }
159
160    #[test]
161    fn url_encoding_preserves_unreserved() {
162        assert_eq!(urlencoding("a.b-c_d~e"), "a.b-c_d~e");
163    }
164
165    #[test]
166    fn source_for_path_followed_sites() {
167        // A page token from a followed-sites continuation must resolve to Followed.
168        let path = "/me/followedSites?$skiptoken=XYZ";
169        assert!(matches!(source_for_path(path), SiteListSource::Followed));
170
171        // A page token from a search continuation must resolve to Search.
172        let path = "/sites?search=intranet&$skiptoken=ABC";
173        assert!(matches!(source_for_path(path), SiteListSource::Search));
174    }
175}