Skip to main content

sharepoint_cli/commands/
sites.rs

1//! `sharepoint sites list | use`
2
3use crate::auth::AuthContext;
4use crate::cli::{Runtime, SitesCmd};
5use crate::config;
6use crate::error::{CliError, Result};
7use crate::graph::{Cursor, GraphClient, decode_cursor, encode_cursor, sites};
8
9pub async fn run(rt: &Runtime, cmd: SitesCmd) -> Result<()> {
10    match cmd {
11        SitesCmd::List {
12            query,
13            limit,
14            all,
15            page,
16            fields,
17        } => list(rt, query.as_deref(), limit, all, page.as_deref(), &fields).await,
18        SitesCmd::Use { site } => use_site(rt, &site).await,
19    }
20}
21
22async fn list(
23    rt: &Runtime,
24    query: Option<&str>,
25    limit: usize,
26    all: bool,
27    page: Option<&str>,
28    fields: &[String],
29) -> Result<()> {
30    let auth = AuthContext::new(rt.cfg.clone(), rt.cache_path.clone());
31    let graph = GraphClient::new(auth);
32
33    // Decode the incoming page token to a (url, skip) cursor.
34    let (mut current_url, mut skip) = if let Some(token) = page {
35        let endpoint = graph.graph_endpoint().await;
36        let cursor = decode_cursor(&endpoint, token)?;
37        (cursor.next, cursor.skip)
38    } else {
39        (None, 0)
40    };
41
42    let mut items = Vec::new();
43    let mut source_label: &str;
44
45    let out_cursor: Option<Cursor> = 'outer: loop {
46        let res = sites::list(&graph, query, current_url.as_deref()).await?;
47        source_label = match res.source {
48            sites::SiteListSource::Followed => "followed",
49            sites::SiteListSource::Search => "search",
50        };
51
52        for (idx, s) in res.items.iter().enumerate() {
53            if idx < skip {
54                continue;
55            }
56            items.push(s.clone());
57            if !all && items.len() >= limit {
58                // Mid-page: cursor points back at the same URL with updated skip.
59                let consumed_in_page = idx + 1;
60                break 'outer Some(Cursor {
61                    next: Some(res.fetched_url),
62                    skip: consumed_in_page,
63                });
64            }
65        }
66        skip = 0;
67
68        if all {
69            if res.next_url.is_none() {
70                // Exhausted.
71                break None;
72            }
73            current_url = res.next_url;
74        } else {
75            // Not --all: emit next cursor pointing at the Graph nextLink.
76            break res.next_url.map(|url| Cursor {
77                next: Some(url),
78                skip: 0,
79            });
80        }
81    };
82
83    let next_token = out_cursor.as_ref().map(encode_cursor);
84    let total = items.len();
85    if rt.out.json {
86        let json_items: Vec<_> = items
87            .iter()
88            .map(|s| {
89                filter_fields(
90                    serde_json::json!({
91                        "id": s.id,
92                        "name": s.display_name,
93                        "url": s.web_url,
94                    }),
95                    fields,
96                )
97            })
98            .collect();
99        rt.out.print_json(&serde_json::json!({
100            "total": total,
101            "next": next_token,
102            "source": source_label,
103            "items": json_items,
104        }));
105    } else {
106        for s in &items {
107            rt.out
108                .print_data(&format!("{:40}  {}", s.display_name, s.web_url));
109        }
110        rt.out
111            .print_message(&format!("({total} site(s), source={source_label})"));
112    }
113    Ok(())
114}
115
116async fn use_site(rt: &Runtime, value: &str) -> Result<()> {
117    if rt.cfg.read_only {
118        return Err(CliError::ReadOnly(
119            "sites use modifies the config file; not allowed in read-only mode".into(),
120        ));
121    }
122    let mut file = rt.config_file.clone();
123    let entry = file.profile.entry(rt.cfg.profile_name.clone()).or_default();
124    entry.default_site = Some(value.to_string());
125    config::save_file(&rt.config_path, &file)?;
126    rt.out.print_message(&format!(
127        "Set default_site for profile '{}' to '{}'",
128        rt.cfg.profile_name, value
129    ));
130    if rt.out.json {
131        rt.out.print_json(&serde_json::json!({
132            "profile": rt.cfg.profile_name,
133            "default_site": value,
134        }));
135    }
136    Ok(())
137}
138
139/// Filter a JSON object to only the specified fields; pass through if `fields` is empty.
140fn filter_fields(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
141    if fields.is_empty() {
142        return value;
143    }
144    if let serde_json::Value::Object(ref mut map) = value {
145        map.retain(|k, _| fields.iter().any(|f| f == k));
146    }
147    value
148}