sharepoint_cli/graph/
sites.rs1use 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 pub(crate) next_url: Option<String>,
36 pub(crate) fetched_url: String,
39 pub(crate) source: SiteListSource,
40}
41
42pub(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 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
74pub(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
86pub(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
142fn 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 let path = "/me/followedSites?$skiptoken=XYZ";
169 assert!(matches!(source_for_path(path), SiteListSource::Followed));
170
171 let path = "/sites?search=intranet&$skiptoken=ABC";
173 assert!(matches!(source_for_path(path), SiteListSource::Search));
174 }
175}